diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b73ec86 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,17 @@ +# 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"] diff --git a/.gitignore b/.gitignore index d27182e..b82768b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ wasm/target/ *.pyd *.dll +# macOS dSYM debug symbols (generated by maturin develop) +*.dSYM/ + +# +/plans/ + # Maturin / wheel build outputs dist/ *.egg-info/ diff --git a/Cargo.lock b/Cargo.lock index d10029c..05ca006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "ferro_ta" -version = "1.0.6" +version = "1.2.0" dependencies = [ "criterion", "ferro_ta_core", @@ -222,9 +222,11 @@ dependencies = [ [[package]] name = "ferro_ta_core" -version = "1.0.6" +version = "1.2.0" dependencies = [ "criterion", + "serde", + "serde_json", "wide", ] diff --git a/Cargo.toml b/Cargo.toml index d6a2ba7..34c77fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [package] name = "ferro_ta" -version = "1.0.6" +version = "1.2.0" 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.0.6" } +ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.2.0", features = ["serde"] } [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/README.md b/README.md index e9b3c75..6a0c491 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,10 @@ 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` 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. +- `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. See the benchmark methodology and artifacts: diff --git a/benchmarks/artifacts/latest/bench_backtest_results.json b/benchmarks/artifacts/latest/bench_backtest_results.json new file mode 100644 index 0000000..1404773 --- /dev/null +++ b/benchmarks/artifacts/latest/bench_backtest_results.json @@ -0,0 +1,153 @@ +{ + "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 + } + ] + } +} diff --git a/benchmarks/artifacts/latest/manifest.json b/benchmarks/artifacts/latest/manifest.json index b285812..1befe60 100644 --- a/benchmarks/artifacts/latest/manifest.json +++ b/benchmarks/artifacts/latest/manifest.json @@ -57,6 +57,11 @@ "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" } } } diff --git a/benchmarks/bench_backtest.py b/benchmarks/bench_backtest.py new file mode 100644 index 0000000..13068b1 --- /dev/null +++ b/benchmarks/bench_backtest.py @@ -0,0 +1,425 @@ +""" +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()) diff --git a/conda/meta.yaml b/conda/meta.yaml index b7492f5..6d903f1 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -1,5 +1,5 @@ {% set name = "ferro-ta" %} -{% set version = "1.0.6" %} +{% set version = "1.2.0" %} package: name: {{ name|lower }} diff --git a/crates/ferro_ta_core/Cargo.toml b/crates/ferro_ta_core/Cargo.toml index eee0fd0..ff4fbbe 100644 --- a/crates/ferro_ta_core/Cargo.toml +++ b/crates/ferro_ta_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferro_ta_core" -version = "1.0.6" +version = "1.2.0" edition = "2021" description = "Pure Rust core indicator library — no PyO3, no numpy dependency" license = "MIT" @@ -17,6 +17,8 @@ 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"] } @@ -28,3 +30,4 @@ harness = false [features] wide = ["dep:wide"] simd = ["wide"] +serde = ["dep:serde", "dep:serde_json"] diff --git a/crates/ferro_ta_core/README.md b/crates/ferro_ta_core/README.md index bcba615..88fd1d8 100644 --- a/crates/ferro_ta_core/README.md +++ b/crates/ferro_ta_core/README.md @@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for: ```toml [dependencies] -ferro_ta_core = "1.0.6" +ferro_ta_core = "1.2.0" ``` ## Design diff --git a/crates/ferro_ta_core/src/aggregation.rs b/crates/ferro_ta_core/src/aggregation.rs new file mode 100644 index 0000000..6d4648d --- /dev/null +++ b/crates/ferro_ta_core/src/aggregation.rs @@ -0,0 +1,346 @@ +//! 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, Vec, Vec, Vec, Vec); + +/// OHLCV 5-tuple plus labels return type alias. +type Ohlcv5AndLabels = (Vec, Vec, Vec, Vec, Vec, Vec); + +// --------------------------------------------------------------------------- +// 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 = Vec::new(); + let mut out_high: Vec = Vec::new(); + let mut out_low: Vec = Vec::new(); + let mut out_close: Vec = Vec::new(); + let mut out_vol: Vec = 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 = Vec::new(); + let mut out_high: Vec = Vec::new(); + let mut out_low: Vec = Vec::new(); + let mut out_close: Vec = Vec::new(); + let mut out_vol: Vec = Vec::new(); + let mut out_labels: Vec = 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(&[], &[], &[]); + } +} diff --git a/crates/ferro_ta_core/src/alerts.rs b/crates/ferro_ta_core/src/alerts.rs new file mode 100644 index 0000000..ac933f4 --- /dev/null +++ b/crates/ferro_ta_core/src/alerts.rs @@ -0,0 +1,130 @@ +//! 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` with `1` at crossing bars, `0` elsewhere. +/// Element 0 is always 0. +pub fn check_threshold(series: &[f64], level: f64, direction: i32) -> Vec { + 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 { + if prev <= level && curr > level { + out[i] = 1; + } + } else if direction == -1 { + if prev >= level && curr < level { + out[i] = 1; + } + } + } + out +} + +/// Detect cross-over / cross-under events between two series. +/// +/// Returns `Vec`: `1` = bullish cross (fast above slow), `-1` = bearish, `0` = none. +/// Element 0 is always 0. +pub fn check_cross(fast: &[f64], slow: &[f64]) -> Vec { + 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 { + 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::::new()); + assert_eq!(check_cross(&[], &[]), Vec::::new()); + assert_eq!(collect_alert_bars(&[]), Vec::::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 + } +} diff --git a/crates/ferro_ta_core/src/attribution.rs b/crates/ferro_ta_core/src/attribution.rs new file mode 100644 index 0000000..59dfb92 --- /dev/null +++ b/crates/ferro_ta_core/src/attribution.rs @@ -0,0 +1,329 @@ +//! 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 = Vec::new(); + let mut losses: Vec = 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::() / wins.len() as f64 + }; + let avg_loss = if losses.is_empty() { + 0.0 + } else { + losses.iter().sum::() / 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::() / 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, Vec) { + let n = bar_returns.len(); + assert_eq!( + n, + month_index.len(), + "bar_returns and month_index must have equal length" + ); + + let mut map: HashMap = 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 = map.keys().copied().collect(); + months.sort_unstable(); + let contributions: Vec = 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, Vec) { + let n = bar_returns.len(); + assert_eq!( + n, + signal_labels.len(), + "bar_returns and signal_labels must have equal length" + ); + + let mut map: HashMap = 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 = map.keys().copied().collect(); + labels.sort_unstable(); + let contributions: Vec = 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, Vec) { + let n = positions.len(); + assert_eq!( + n, + strategy_returns.len(), + "positions and strategy_returns must have equal length" + ); + + let mut pnl = Vec::::new(); + let mut hold = Vec::::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); + } +} diff --git a/crates/ferro_ta_core/src/backtest.rs b/crates/ferro_ta_core/src/backtest.rs new file mode 100644 index 0000000..4dd8f4a --- /dev/null +++ b/crates/ferro_ta_core/src/backtest.rs @@ -0,0 +1,2105 @@ +//! Pure Rust backtest engine — no PyO3, no numpy dependency. +//! +//! This module contains all backtest logic as pure functions operating on +//! `&[f64]` slices. The PyO3 binding crate provides thin wrappers that +//! convert NumPy arrays to slices and call into this module. + +use crate::commission::CommissionModel; + +// --------------------------------------------------------------------------- +// Utility helpers +// --------------------------------------------------------------------------- + +/// Replace NaN → 0, +Inf → f64::MAX, −Inf → −f64::MAX (mirrors numpy nan_to_num defaults). +#[inline] +pub fn nan_to_num(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 + } +} + +/// Kelly criterion formula: f = win_rate − (1 − win_rate) × (|avg_loss| / avg_win), clamped to [0, 1]. +#[inline] +pub fn kelly_formula(win_rate: f64, avg_win: f64, avg_loss: f64) -> f64 { + if avg_win <= 0.0 { + return 0.0; + } + let f = win_rate - (1.0 - win_rate) * (avg_loss.abs() / avg_win); + f.clamp(0.0, 1.0) +} + +/// Deterministic LCG (Knuth MMIX). +#[inline] +pub fn lcg_next(state: &mut u64) -> u64 { + *state = state + .wrapping_mul(6_364_136_223_846_793_005_u64) + .wrapping_add(1_442_695_040_888_963_407_u64); + *state +} + +#[inline] +pub fn lcg_index(state: &mut u64, n: usize) -> usize { + ((lcg_next(state) >> 11) as usize) % n +} + +/// Compute commission cost as a fraction of `initial_capital` for a single execution. +#[inline] +pub fn commission_fraction( + cm: &CommissionModel, + fill_price: f64, + position_size: f64, + is_buy: bool, + initial_capital: f64, +) -> f64 { + if fill_price <= 0.0 || position_size <= 0.0 || initial_capital <= 0.0 { + return 0.0; + } + let trade_value = position_size * fill_price * initial_capital; + let num_lots = if cm.lot_size > 0.0 { + (position_size * initial_capital / (cm.lot_size * fill_price)).ceil() + } else { + 1.0 + }; + cm.cost_fraction(trade_value, num_lots, is_buy, initial_capital) +} + +/// Resolve a CommissionModel from an optional reference or backward-compat scalar. +pub fn resolve_commission_model( + commission: Option<&CommissionModel>, + commission_per_trade: f64, +) -> CommissionModel { + match commission { + Some(c) => c.clone(), + None if commission_per_trade > 0.0 => CommissionModel { + flat_per_order: commission_per_trade, + ..Default::default() + }, + None => CommissionModel::default(), + } +} + +// --------------------------------------------------------------------------- +// Structs +// --------------------------------------------------------------------------- + +/// Configuration for the OHLCV-aware backtester. +#[derive(Clone, Debug)] +pub struct BacktestConfig { + pub fill_mode: String, + pub stop_loss_pct: f64, + pub take_profit_pct: f64, + pub trailing_stop_pct: f64, + pub slippage_bps: f64, + pub initial_capital: f64, + pub commission_per_trade: f64, + pub max_hold_bars: usize, + pub slippage_pct_range: f64, + pub breakeven_pct: f64, + pub periods_per_year: f64, + pub margin_ratio: f64, + pub margin_call_pct: f64, + pub daily_loss_limit: f64, + pub total_loss_limit: f64, + pub commission: Option, +} + +impl Default for BacktestConfig { + fn default() -> Self { + Self { + fill_mode: "market_open".to_string(), + 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, + } + } +} + +/// Result of the OHLCV backtest engine. +#[derive(Clone, Debug)] +pub struct OhlcvBacktestResult { + pub positions: Vec, + pub fill_prices: Vec, + pub bar_returns: Vec, + pub strategy_returns: Vec, + pub equity: Vec, +} + +/// A single completed trade record. +#[derive(Clone, Debug)] +pub struct TradeRecord { + pub entry_bar: i64, + pub exit_bar: i64, + pub direction: f64, + pub entry_price: f64, + pub exit_price: f64, + pub pnl_pct: f64, + pub duration_bars: i64, + pub mae: f64, + pub mfe: f64, +} + +/// Comprehensive performance metrics. +#[derive(Clone, Debug)] +pub struct BacktestMetrics { + pub total_return: f64, + pub cagr: f64, + pub annualized_vol: f64, + pub sharpe: f64, + pub sortino: f64, + pub calmar: f64, + pub max_drawdown: f64, + pub avg_drawdown: f64, + pub max_drawdown_duration_bars: usize, + pub avg_drawdown_duration_bars: f64, + pub ulcer_index: f64, + pub omega_ratio: f64, + pub win_rate: f64, + pub profit_factor: f64, + pub r_expectancy: f64, + pub avg_win: f64, + pub avg_loss: f64, + pub tail_ratio: f64, + pub skewness: f64, + pub kurtosis: f64, + pub best_bar: f64, + pub worst_bar: f64, + pub n_trades: usize, + pub n_position_changes: usize, + // Optional benchmark metrics + pub benchmark_total_return: Option, + pub benchmark_cagr: Option, + pub benchmark_annualized_vol: Option, + pub benchmark_sharpe: Option, + pub alpha: Option, + pub beta: Option, + pub tracking_error: Option, + pub information_ratio: Option, +} + +/// Mutable state bundle for the OHLCV backtest loop. +pub struct OhlcvState { + pub current_pos: f64, + pub entry_price: f64, + pub trail_high: f64, + pub trail_low: f64, + pub breakeven_activated: bool, + pub breakeven_stop: f64, + pub bars_in_trade: usize, + pub margin_entry_price: f64, + pub initial_margin_required: f64, +} + +impl OhlcvState { + pub fn new() -> Self { + Self { + current_pos: 0.0, + entry_price: f64::NAN, + trail_high: f64::NAN, + trail_low: f64::NAN, + breakeven_activated: false, + breakeven_stop: f64::NAN, + bars_in_trade: 0, + margin_entry_price: f64::NAN, + initial_margin_required: 0.0, + } + } + + #[inline] + pub fn close_position(&mut self) { + self.current_pos = 0.0; + self.entry_price = f64::NAN; + self.trail_high = f64::NAN; + self.trail_low = f64::NAN; + self.breakeven_activated = false; + self.breakeven_stop = f64::NAN; + self.bars_in_trade = 0; + self.margin_entry_price = f64::NAN; + self.initial_margin_required = 0.0; + } +} + +impl Default for OhlcvState { + fn default() -> Self { + Self::new() + } +} + +/// Stateful streaming backtester — feed one bar at a time. +#[derive(Clone, Debug)] +pub struct StreamingBacktest { + pub commission_per_trade: f64, + pub slippage_bps: f64, + pub position: f64, + pub entry_price: f64, + pub equity: f64, + pub prev_close: f64, + pub total_commission: f64, + pub n_trades: usize, + pub sum_wins: f64, + pub n_wins: usize, + pub sum_losses: f64, + pub n_losses: usize, +} + +/// Result of a single `StreamingBacktest::on_bar` call. +#[derive(Clone, Debug)] +pub struct StreamingBarResult { + pub position: f64, + pub bar_return: f64, + pub equity: f64, + pub n_trades: usize, +} + +/// Summary statistics from StreamingBacktest. +#[derive(Clone, Debug)] +pub struct StreamingSummary { + pub equity: f64, + pub n_trades: usize, + pub total_commission: f64, + pub win_rate: f64, + pub avg_win: f64, + pub avg_loss: f64, + pub kelly_fraction: f64, +} + +// --------------------------------------------------------------------------- +// Signal generators +// --------------------------------------------------------------------------- + +/// RSI threshold strategy: +1 when RSI <= oversold, -1 when RSI >= overbought, 0 otherwise. +pub fn rsi_threshold_signals(close: &[f64], timeperiod: usize, oversold: f64, overbought: f64) -> Vec { + let rsi = crate::momentum::rsi(close, timeperiod); + 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() +} + +/// SMA crossover strategy: +1 when fast SMA > slow SMA, -1 otherwise. Warm-up bars are NaN. +/// +/// Returns `Err` if `fast >= slow`. +pub fn sma_crossover_signals(close: &[f64], fast: usize, slow: usize) -> Result, String> { + if fast >= slow { + return Err(format!("fast ({fast}) must be less than slow ({slow})")); + } + let sma_fast = crate::overlap::sma(close, fast); + let sma_slow = crate::overlap::sma(close, slow); + Ok(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()) +} + +/// MACD crossover strategy: +1 when MACD line > signal line, -1 otherwise. +/// +/// Returns `Err` if `fastperiod >= slowperiod`. +pub fn macd_crossover_signals( + close: &[f64], + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> Result, String> { + if fastperiod >= slowperiod { + return Err(format!( + "fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})" + )); + } + let (macd_line, signal_line, _) = + crate::overlap::macd(close, fastperiod, slowperiod, signalperiod); + Ok(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()) +} + +// --------------------------------------------------------------------------- +// Core backtest (close-only) +// --------------------------------------------------------------------------- + +/// Backtest result from the simple close-only engine. +#[derive(Clone, Debug)] +pub struct BacktestCoreResult { + pub positions: Vec, + pub bar_returns: Vec, + pub strategy_returns: Vec, + pub equity: Vec, +} + +/// Backtest core loop over close prices and strategy signals. +/// +/// Uses the full `CommissionModel` if provided, otherwise falls back to +/// `commission_per_trade` as a flat per-order fee. +pub fn backtest_core( + close: &[f64], + signals: &[f64], + commission: Option<&CommissionModel>, + slippage_bps: f64, + initial_capital: f64, + commission_per_trade: f64, +) -> Result { + let n = close.len(); + if n != signals.len() { + return Err(format!( + "close length ({}) != signals length ({})", + n, + signals.len() + )); + } + + let mut positions = vec![0.0_f64; n]; + if n > 1 { + for i in 1..n { + positions[i] = nan_to_num(signals[i - 1]); + } + } + + let mut bar_returns = vec![0.0_f64; n]; + for i in 1..n { + bar_returns[i] = (close[i] - close[i - 1]) / close[i - 1]; + } + + 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]).abs() > 1e-12; + } + + 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; + } + } + } + + let cm = resolve_commission_model(commission, commission_per_trade); + + let mut equity = vec![1.0_f64; n]; + let mut cum = 1.0_f64; + for i in 0..n { + cum *= 1.0 + strategy_returns[i]; + if position_changed[i] { + let prev_pos = if i > 0 { positions[i - 1] } else { 0.0 }; + let cost = commission_fraction( + &cm, + if close[i] != 0.0 { close[i] } else { 1.0 }, + (positions[i] - prev_pos).abs(), + positions[i] > prev_pos, + initial_capital, + ); + cum -= cost; + } + equity[i] = cum; + } + + Ok(BacktestCoreResult { + positions, + bar_returns, + strategy_returns, + equity, + }) +} + +/// Core single-asset loop reused by multi-asset backtest. +pub fn single_asset_backtest( + close: &[f64], + signals: &[f64], + commission_per_trade: f64, + slippage_bps: f64, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let slip = slippage_bps / 10_000.0; + + let mut positions = vec![0.0_f64; n]; + for i in 1..n { + positions[i] = nan_to_num(signals[i - 1]); + } + + let mut bar_returns = vec![0.0_f64; n]; + for i in 1..n { + if close[i - 1] != 0.0 { + bar_returns[i] = (close[i] - close[i - 1]) / close[i - 1]; + } + } + + 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]).abs() > 1e-12; + } + + if slip > 0.0 { + for i in 0..n { + if position_changed[i] { + strategy_returns[i] -= slip; + } + } + } + + let mut equity = vec![1.0_f64; n]; + if commission_per_trade <= 0.0 { + let mut g = 1.0_f64; + for i in 0..n { + g *= 1.0 + strategy_returns[i]; + equity[i] = g; + } + } else { + let mut gross = vec![1.0_f64; n]; + let mut g = 1.0_f64; + for i in 0..n { + g *= 1.0 + strategy_returns[i]; + gross[i] = g; + } + let has_zero = gross.contains(&0.0); + if has_zero { + 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 disc = 0.0_f64; + for i in 0..n { + if position_changed[i] { + disc += commission_per_trade / gross[i]; + } + equity[i] = gross[i] * (1.0 - disc); + } + } + } + + (positions, strategy_returns, equity) +} + +// --------------------------------------------------------------------------- +// OHLCV-aware backtest engine +// --------------------------------------------------------------------------- + +/// Full OHLCV backtest with stop loss, take profit, trailing stops, breakeven, +/// margin calls, circuit breakers, limit orders, and short borrow costs. +pub fn backtest_ohlcv_core( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + signals: &[f64], + config: &BacktestConfig, + limit_prices: Option<&[f64]>, +) -> Result { + let n = close.len(); + if n < 2 { + return Err("arrays must have at least 2 elements".to_string()); + } + if open.len() != n || high.len() != n || low.len() != n || signals.len() != n { + return Err(format!( + "all arrays must have equal length (close={}), got open={}, high={}, low={}, signals={}", + n, + open.len(), + high.len(), + low.len(), + signals.len() + )); + } + + let use_open_fill = config.fill_mode != "market_close"; + let cm = resolve_commission_model(config.commission.as_ref(), config.commission_per_trade); + + let stop_loss_pct = config.stop_loss_pct; + let take_profit_pct = config.take_profit_pct; + let trailing_stop_pct = config.trailing_stop_pct; + let slippage_bps = config.slippage_bps; + let initial_capital = config.initial_capital; + let max_hold_bars = config.max_hold_bars; + let slippage_pct_range = config.slippage_pct_range; + let breakeven_pct = config.breakeven_pct; + let periods_per_year = config.periods_per_year; + let margin_ratio = config.margin_ratio; + let margin_call_pct = config.margin_call_pct; + let daily_loss_limit = config.daily_loss_limit; + let total_loss_limit = config.total_loss_limit; + + let mut positions = vec![0.0_f64; n]; + let mut fill_prices = vec![f64::NAN; n]; + let mut bar_returns = vec![0.0_f64; n]; + let mut strategy_returns = vec![0.0_f64; n]; + + let mut st = OhlcvState::new(); + let default_slip = slippage_bps / 10_000.0; + + let mut circuit_broken: bool = false; + let mut running_equity: f64 = 1.0; + + for i in 1..n { + let pos_start = st.current_pos; + let desired_pos = nan_to_num(signals[i - 1]); + + // --- Margin call check (at bar open) --- + if margin_ratio > 0.0 + && st.current_pos != 0.0 + && !st.margin_entry_price.is_nan() + && st.initial_margin_required > 0.0 + { + let position_pnl = + st.current_pos * (open[i] - st.margin_entry_price) / st.margin_entry_price; + let margin_equity = st.initial_margin_required + position_pnl; + if margin_equity <= margin_call_pct * st.initial_margin_required { + let mc_fill = open[i]; + let mc_ret = if close[i - 1] != 0.0 { + st.current_pos * (mc_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let comm = commission_fraction( + &cm, + mc_fill, + st.current_pos.abs(), + st.current_pos < 0.0, + initial_capital, + ); + strategy_returns[i] = mc_ret - comm; + fill_prices[i] = mc_fill; + st.close_position(); + positions[i] = 0.0; + continue; + } + } + + let slip: f64 = if slippage_pct_range > 0.0 && close[i] > 0.0 { + slippage_pct_range * (high[i] - low[i]) / close[i] + } else { + default_slip + }; + + if trailing_stop_pct > 0.0 { + if st.current_pos > 0.0 && !st.trail_high.is_nan() { + st.trail_high = st.trail_high.max(high[i]); + } + if st.current_pos < 0.0 && !st.trail_low.is_nan() { + st.trail_low = st.trail_low.min(low[i]); + } + } + + let close_ret = if close[i - 1] != 0.0 { + (close[i] - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + bar_returns[i] = close_ret; + + let mut forced_close = false; + + // --- Circuit breaker check --- + if i > 1 { + running_equity *= 1.0 + strategy_returns[i - 1]; + } + if !circuit_broken { + if daily_loss_limit > 0.0 && i > 1 && strategy_returns[i - 1] < -daily_loss_limit { + circuit_broken = true; + } + if total_loss_limit > 0.0 && running_equity < 1.0 - total_loss_limit { + circuit_broken = true; + } + } + if circuit_broken && st.current_pos != 0.0 { + let base_fill = if use_open_fill { open[i] } else { close[i] }; + let is_buy = st.current_pos < 0.0; + let close_r = if close[i - 1] != 0.0 { + st.current_pos * (base_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let comm = commission_fraction( + &cm, + base_fill, + st.current_pos.abs(), + is_buy, + initial_capital, + ); + strategy_returns[i] = close_r - comm; + fill_prices[i] = base_fill; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + if circuit_broken { + positions[i] = 0.0; + continue; + } + + // ---- Intrabar trailing stop check ---- + if trailing_stop_pct > 0.0 && st.current_pos != 0.0 && !st.entry_price.is_nan() { + if st.current_pos > 0.0 && !st.trail_high.is_nan() { + let trail_stop = st.trail_high * (1.0 - trailing_stop_pct); + if low[i] <= trail_stop { + let stop_ret = if close[i - 1] != 0.0 { + (trail_stop - close[i - 1]) / close[i - 1] + } else { + -trailing_stop_pct + }; + let comm = commission_fraction( + &cm, + trail_stop, + st.current_pos.abs(), + false, + initial_capital, + ); + strategy_returns[i] = st.current_pos * stop_ret - slip - comm; + fill_prices[i] = trail_stop; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + } else if st.current_pos < 0.0 && !st.trail_low.is_nan() { + let trail_stop = st.trail_low * (1.0 + trailing_stop_pct); + if high[i] >= trail_stop { + let stop_ret = if close[i - 1] != 0.0 { + (trail_stop - close[i - 1]) / close[i - 1] + } else { + trailing_stop_pct + }; + let comm = commission_fraction( + &cm, + trail_stop, + st.current_pos.abs(), + true, + initial_capital, + ); + strategy_returns[i] = st.current_pos * stop_ret - slip - comm; + fill_prices[i] = trail_stop; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + } + } + + // ---- Breakeven stop activation ---- + if breakeven_pct > 0.0 + && st.current_pos != 0.0 + && !st.entry_price.is_nan() + && !st.breakeven_activated + { + let condition_met = if st.current_pos > 0.0 { + high[i] >= st.entry_price * (1.0 + breakeven_pct) + } else { + low[i] <= st.entry_price * (1.0 - breakeven_pct) + }; + if condition_met { + st.breakeven_activated = true; + st.breakeven_stop = st.entry_price; + } + } + + // ---- Intrabar SL/TP combined bracket check ---- + { + let has_stop = st.breakeven_activated || stop_loss_pct > 0.0; + let stop_long = if st.breakeven_activated { + st.breakeven_stop + } else { + st.entry_price * (1.0 - stop_loss_pct) + }; + let stop_short = if st.breakeven_activated { + st.breakeven_stop + } else { + st.entry_price * (1.0 + stop_loss_pct) + }; + let has_tp = take_profit_pct > 0.0; + let tp_long = st.entry_price * (1.0 + take_profit_pct); + let tp_short = st.entry_price * (1.0 - take_profit_pct); + + if !forced_close && st.current_pos != 0.0 && !st.entry_price.is_nan() { + let (exit_price, did_exit) = if st.current_pos > 0.0 { + let sl_hit = has_stop && low[i] <= stop_long; + let tp_hit = has_tp && high[i] >= tp_long; + match (sl_hit, tp_hit) { + (true, true) => { + if (open[i] - stop_long).abs() < (tp_long - open[i]).abs() { + (stop_long, true) + } else { + (tp_long, true) + } + } + (true, false) => (stop_long, true), + (false, true) => (tp_long, true), + _ => (0.0, false), + } + } else { + let sl_hit = has_stop && high[i] >= stop_short; + let tp_hit = has_tp && low[i] <= tp_short; + match (sl_hit, tp_hit) { + (true, true) => { + if (stop_short - open[i]).abs() < (open[i] - tp_short).abs() { + (stop_short, true) + } else { + (tp_short, true) + } + } + (true, false) => (stop_short, true), + (false, true) => (tp_short, true), + _ => (0.0, false), + } + }; + + if did_exit { + let exit_ret = if close[i - 1] != 0.0 { + (exit_price - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let is_buy = st.current_pos < 0.0; + let comm = commission_fraction( + &cm, + exit_price, + st.current_pos.abs(), + is_buy, + initial_capital, + ); + strategy_returns[i] = st.current_pos * exit_ret - slip - comm; + fill_prices[i] = exit_price; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + } + } + + // ---- Time-based exit check ---- + if !forced_close + && max_hold_bars > 0 + && st.current_pos != 0.0 + && st.bars_in_trade >= max_hold_bars + { + let base_fill = if use_open_fill { open[i] } else { close[i] }; + let is_buy = st.current_pos < 0.0; + let actual_fill = if is_buy { + base_fill * (1.0 + slip) + } else { + base_fill * (1.0 - slip) + }; + let exit_ret = if close[i - 1] != 0.0 { + st.current_pos * (actual_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let comm = commission_fraction( + &cm, + actual_fill, + st.current_pos.abs(), + is_buy, + initial_capital, + ); + strategy_returns[i] = exit_ret - comm; + fill_prices[i] = actual_fill; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + + if !forced_close { + // ---- Limit order check ---- + let raw_change = (desired_pos - st.current_pos).abs() > 1e-12; + let (effective_desired_pos, limit_override_price): (f64, Option) = if raw_change { + match limit_prices { + Some(lp) => { + let lp_val = lp[i - 1]; + if lp_val.is_nan() { + (desired_pos, None) + } else { + let is_buy = desired_pos > st.current_pos; + if (is_buy && low[i] <= lp_val) || (!is_buy && high[i] >= lp_val) { + (desired_pos, Some(lp_val)) + } else { + (st.current_pos, None) + } + } + } + None => (desired_pos, None), + } + } else { + (desired_pos, None) + }; + + let pos_changed = (effective_desired_pos - st.current_pos).abs() > 1e-12; + let base_fill_raw = if use_open_fill { open[i] } else { close[i] }; + let base_fill = limit_override_price.unwrap_or(base_fill_raw); + + let actual_fill = if effective_desired_pos > st.current_pos { + base_fill * (1.0 + slip) + } else if effective_desired_pos < st.current_pos { + base_fill * (1.0 - slip) + } else { + base_fill + }; + + if pos_changed { + fill_prices[i] = actual_fill; + if effective_desired_pos != 0.0 { + st.entry_price = actual_fill; + if trailing_stop_pct > 0.0 { + if effective_desired_pos > 0.0 { + st.trail_high = actual_fill; + st.trail_low = f64::NAN; + } else { + st.trail_low = actual_fill; + st.trail_high = f64::NAN; + } + } + } else { + st.entry_price = f64::NAN; + st.trail_high = f64::NAN; + st.trail_low = f64::NAN; + st.breakeven_activated = false; + st.breakeven_stop = f64::NAN; + } + if effective_desired_pos != 0.0 + && st.current_pos != 0.0 + && (effective_desired_pos.signum() != st.current_pos.signum()) + { + st.breakeven_activated = false; + st.breakeven_stop = f64::NAN; + } + } + + strategy_returns[i] = if pos_changed && use_open_fill && actual_fill != 0.0 { + if effective_desired_pos != 0.0 && st.current_pos == 0.0 { + let r = effective_desired_pos * (close[i] - actual_fill) / actual_fill; + let comm = commission_fraction( + &cm, + actual_fill, + effective_desired_pos.abs(), + effective_desired_pos > 0.0, + initial_capital, + ); + r - comm + } else if effective_desired_pos == 0.0 { + let r = if close[i - 1] != 0.0 { + st.current_pos * (actual_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let comm = commission_fraction( + &cm, + actual_fill, + st.current_pos.abs(), + st.current_pos < 0.0, + initial_capital, + ); + r - comm + } else { + let exit_r = if close[i - 1] != 0.0 { + st.current_pos * (actual_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let entry_r = effective_desired_pos * (close[i] - actual_fill) / actual_fill; + let exit_comm = commission_fraction( + &cm, + actual_fill, + st.current_pos.abs(), + st.current_pos < 0.0, + initial_capital, + ); + let entry_comm = commission_fraction( + &cm, + actual_fill, + effective_desired_pos.abs(), + effective_desired_pos > 0.0, + initial_capital, + ); + exit_r + entry_r - exit_comm - entry_comm + } + } else { + let r = st.current_pos * close_ret; + if pos_changed { + let comm = commission_fraction( + &cm, + if close[i] != 0.0 { close[i] } else { 1.0 }, + (effective_desired_pos - st.current_pos).abs(), + effective_desired_pos > st.current_pos, + initial_capital, + ); + r - comm + } else { + r + } + }; + + if pos_changed && margin_ratio > 0.0 { + if effective_desired_pos != 0.0 { + if st.current_pos == 0.0 + || (st.current_pos.signum() != effective_desired_pos.signum()) + { + st.initial_margin_required = effective_desired_pos.abs() * margin_ratio; + st.margin_entry_price = actual_fill; + } + } else { + st.initial_margin_required = 0.0; + st.margin_entry_price = f64::NAN; + } + } + + st.current_pos = effective_desired_pos; + positions[i] = st.current_pos; + } + + // --- Short borrow cost accrual --- + if st.current_pos < 0.0 && cm.short_borrow_rate_annual > 0.0 { + let fill_price_for_borrow = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 { + fill_prices[i] + } else { + close[i] + }; + let trade_value = st.current_pos.abs() * fill_price_for_borrow * initial_capital; + let borrow_cost_fraction = + cm.short_borrow_cost(trade_value, periods_per_year) / initial_capital; + strategy_returns[i] -= borrow_cost_fraction; + } + + // Update bars_in_trade counter + if st.current_pos == 0.0 { + st.bars_in_trade = 0; + } else if pos_start == 0.0 || (pos_start.signum() != st.current_pos.signum()) { + st.bars_in_trade = 1; + } else { + st.bars_in_trade += 1; + } + } + + // Build equity curve + let mut equity = vec![1.0_f64; n]; + let mut cum = 1.0_f64; + for i in 0..n { + cum *= 1.0 + strategy_returns[i]; + equity[i] = cum; + } + + Ok(OhlcvBacktestResult { + positions, + fill_prices, + bar_returns, + strategy_returns, + equity, + }) +} + +// --------------------------------------------------------------------------- +// Performance metrics +// --------------------------------------------------------------------------- + +/// Compute all industry-standard performance metrics from strategy returns and equity. +pub fn compute_performance_metrics( + strategy_returns: &[f64], + equity: &[f64], + periods_per_year: f64, + risk_free_rate: f64, + benchmark_returns: Option<&[f64]>, +) -> Result { + let r = strategy_returns; + let eq = equity; + let n = r.len(); + + if n < 2 { + return Err("strategy_returns must have at least 2 elements".to_string()); + } + if eq.len() != n { + return Err("equity and strategy_returns must have equal length".to_string()); + } + + // --- Pass 1: drawdown / equity stats --- + let mut peak = eq[0]; + let mut max_dd = 0.0_f64; + let mut dd_sum = 0.0_f64; + let mut dd_count = 0_usize; + let mut ulcer_sum = 0.0_f64; + let mut current_dd_len = 0_usize; + let mut max_dd_len = 0_usize; + let mut dd_len_sum = 0_usize; + let mut dd_len_count = 0_usize; + + for &eq_val in eq.iter().take(n) { + if eq_val > peak { + if current_dd_len > 0 { + dd_len_sum += current_dd_len; + dd_len_count += 1; + current_dd_len = 0; + } + peak = eq_val; + } + let dd = if peak != 0.0 { + (eq_val - peak) / peak + } else { + 0.0 + }; + if dd < 0.0 { + dd_sum += dd; + dd_count += 1; + ulcer_sum += dd * dd; + current_dd_len += 1; + if dd < max_dd { + max_dd = dd; + } + if current_dd_len > max_dd_len { + max_dd_len = current_dd_len; + } + } + } + if current_dd_len > 0 { + dd_len_sum += current_dd_len; + dd_len_count += 1; + } + + let avg_dd = if dd_count > 0 { + dd_sum / dd_count as f64 + } else { + 0.0 + }; + let ulcer_index = (ulcer_sum / n as f64).sqrt(); + let avg_dd_duration = if dd_len_count > 0 { + dd_len_sum as f64 / dd_len_count as f64 + } else { + 0.0 + }; + + // --- Pass 2: statistical moments --- + let rf_per_bar = risk_free_rate / periods_per_year; + + let valid_r: Vec = r.iter().copied().filter(|v| v.is_finite()).collect(); + let n_valid = valid_r.len(); + if n_valid == 0 { + return Err("No finite values in strategy_returns".to_string()); + } + + let mean_r: f64 = valid_r.iter().sum::() / n_valid as f64; + let variance: f64 = + valid_r.iter().map(|&v| (v - mean_r).powi(2)).sum::() / n_valid as f64; + let std_r = variance.sqrt(); + + let downside_sq_sum: f64 = valid_r + .iter() + .filter(|&&v| v < rf_per_bar) + .map(|&v| (v - rf_per_bar).powi(2)) + .sum(); + let downside_std = (downside_sq_sum / n_valid as f64).sqrt(); + + let skewness = if std_r > 0.0 { + valid_r + .iter() + .map(|&v| ((v - mean_r) / std_r).powi(3)) + .sum::() + / n_valid as f64 + } else { + 0.0 + }; + let kurtosis = if std_r > 0.0 { + valid_r + .iter() + .map(|&v| ((v - mean_r) / std_r).powi(4)) + .sum::() + / n_valid as f64 + - 3.0 + } else { + 0.0 + }; + + let total_return = if eq[0] != 0.0 { + eq[n - 1] / eq[0] - 1.0 + } else { + 0.0 + }; + let cagr = if eq[0] != 0.0 && eq[n - 1] > 0.0 { + (eq[n - 1] / eq[0]).powf(periods_per_year / n as f64) - 1.0 + } else { + 0.0 + }; + let annual_vol = std_r * periods_per_year.sqrt(); + let sharpe = if annual_vol > 0.0 { + (cagr - risk_free_rate) / annual_vol + } else { + 0.0 + }; + let sortino = if downside_std > 0.0 { + (cagr - risk_free_rate) / (downside_std * periods_per_year.sqrt()) + } else { + 0.0 + }; + let calmar = if max_dd < 0.0 { + cagr / max_dd.abs() + } else { + 0.0 + }; + + // Win / loss analysis — single pass with running counters + let mut n_active = 0_usize; + let mut n_wins = 0_usize; + let mut n_losses = 0_usize; + let mut win_sum = 0.0_f64; + let mut loss_sum = 0.0_f64; + for &v in &valid_r { + if v != 0.0 { + n_active += 1; + if v > 0.0 { + n_wins += 1; + win_sum += v; + } else { + n_losses += 1; + loss_sum += v.abs(); + } + } + } + let win_rate = if n_active > 0 { + n_wins as f64 / n_active as f64 + } else { + 0.0 + }; + let avg_win = if n_wins > 0 { + win_sum / n_wins as f64 + } else { + 0.0 + }; + let avg_loss = if n_losses > 0 { + -(loss_sum / n_losses as f64) + } else { + 0.0 + }; + let profit_factor = if loss_sum > 0.0 { + win_sum / loss_sum + } else { + f64::INFINITY + }; + let loss_rate = 1.0 - win_rate; + let r_expectancy = win_rate * avg_win - loss_rate * avg_loss.abs(); + + // Omega ratio + let omega_numer: f64 = valid_r + .iter() + .filter(|&&v| v > rf_per_bar) + .map(|&v| v - rf_per_bar) + .sum(); + let omega_denom: f64 = valid_r + .iter() + .filter(|&&v| v <= rf_per_bar) + .map(|&v| rf_per_bar - v) + .sum(); + let omega_ratio = if omega_denom > 0.0 { + omega_numer / omega_denom + } else { + f64::INFINITY + }; + + // Tail ratio — use select_nth_unstable for O(n) percentile lookup + let mut pct_r = valid_r.clone(); + let idx_5 = ((n_valid as f64 * 0.05) as usize).min(n_valid.saturating_sub(1)); + let idx_95 = ((n_valid as f64 * 0.95) as usize).min(n_valid.saturating_sub(1)); + // Find 5th percentile (also partitions so all elements below idx_5 are <=) + pct_r.select_nth_unstable_by(idx_5, |a, b| { + a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal) + }); + let p5 = pct_r[idx_5]; + let worst_bar = pct_r[..=idx_5].iter().copied().fold(f64::INFINITY, f64::min); + // Find 95th percentile in the remaining upper partition + pct_r[idx_5..].select_nth_unstable_by(idx_95 - idx_5, |a, b| { + a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal) + }); + let p95 = pct_r[idx_95]; + let best_bar = pct_r[idx_95..].iter().copied().fold(f64::NEG_INFINITY, f64::max); + let tail_ratio = if p5.abs() > 0.0 { + p95.abs() / p5.abs() + } else { + f64::INFINITY + }; + + // Position changes + let mut n_pos_changes = 0_usize; + for i in 1..n { + let prev_active = r[i - 1].is_finite() && r[i - 1] != 0.0; + let cur_active = r[i].is_finite() && r[i] != 0.0; + if prev_active != cur_active { + n_pos_changes += 1; + } + } + + // Benchmark metrics + let ( + benchmark_total_return, + benchmark_cagr, + benchmark_annualized_vol, + benchmark_sharpe, + alpha, + beta, + tracking_error, + information_ratio, + ) = if let Some(br) = benchmark_returns { + if br.len() == n { + let mut s_sum = 0.0_f64; + let mut b_sum = 0.0_f64; + let mut nb = 0_usize; + for i in 0..n { + if r[i].is_finite() && br[i].is_finite() { + s_sum += r[i]; + b_sum += br[i]; + nb += 1; + } + } + if nb > 1 { + let s_mean = s_sum / nb as f64; + let b_mean = b_sum / nb as f64; + + let mut b_var_sum = 0.0_f64; + let mut cov_sum = 0.0_f64; + let mut ex_sum = 0.0_f64; + let mut ex_sq_sum = 0.0_f64; + for i in 0..n { + if r[i].is_finite() && br[i].is_finite() { + let sd = r[i] - s_mean; + let bd = br[i] - b_mean; + b_var_sum += bd * bd; + cov_sum += sd * bd; + let ex = r[i] - br[i]; + ex_sum += ex; + ex_sq_sum += ex * ex; + } + } + let b_var = b_var_sum / nb as f64; + let b_std = b_var.sqrt(); + + let mut b_eq = 1.0_f64; + for &ret in br { + b_eq *= 1.0 + if ret.is_finite() { ret } else { 0.0 }; + } + let bench_total_return = b_eq - 1.0; + let bench_cagr = if b_eq > 0.0 { + b_eq.powf(periods_per_year / n as f64) - 1.0 + } else { + 0.0 + }; + let bench_ann_vol = b_std * periods_per_year.sqrt(); + let bench_sharpe = if bench_ann_vol > 0.0 { + (bench_cagr - risk_free_rate) / bench_ann_vol + } else { + 0.0 + }; + + let cov_val = cov_sum / nb as f64; + let beta_val = if b_var > 0.0 { cov_val / b_var } else { 0.0 }; + let alpha_val = cagr - bench_cagr; + + let ex_mean = ex_sum / nb as f64; + let ex_var = ex_sq_sum / nb as f64 - ex_mean * ex_mean; + let te = ex_var.max(0.0).sqrt() * periods_per_year.sqrt(); + let ir = if te > 0.0 { alpha_val / te } else { 0.0 }; + + ( + Some(bench_total_return), + Some(bench_cagr), + Some(bench_ann_vol), + Some(bench_sharpe), + Some(alpha_val), + Some(beta_val), + Some(te), + Some(ir), + ) + } else { + (None, None, None, None, None, None, None, None) + } + } else { + (None, None, None, None, None, None, None, None) + } + } else { + (None, None, None, None, None, None, None, None) + }; + + Ok(BacktestMetrics { + total_return, + cagr, + annualized_vol: annual_vol, + sharpe, + sortino, + calmar, + max_drawdown: max_dd, + avg_drawdown: avg_dd, + max_drawdown_duration_bars: max_dd_len, + avg_drawdown_duration_bars: avg_dd_duration, + ulcer_index, + omega_ratio, + win_rate, + profit_factor, + r_expectancy, + avg_win, + avg_loss, + tail_ratio, + skewness, + kurtosis, + best_bar, + worst_bar, + n_trades: n_active, + n_position_changes: n_pos_changes, + benchmark_total_return, + benchmark_cagr, + benchmark_annualized_vol, + benchmark_sharpe, + alpha, + beta, + tracking_error, + information_ratio, + }) +} + +// --------------------------------------------------------------------------- +// Trade extraction +// --------------------------------------------------------------------------- + +/// Extract trade records from positions and price arrays. +pub fn extract_trades_ohlcv( + positions: &[f64], + fill_prices: &[f64], + high: &[f64], + low: &[f64], +) -> Result, String> { + let n = positions.len(); + if fill_prices.len() != n || high.len() != n || low.len() != n { + return Err(format!( + "all arrays must have equal length (positions={}), got fill_prices={}, high={}, low={}", + n, + fill_prices.len(), + high.len(), + low.len() + )); + } + + let mut trades: Vec = Vec::new(); + + let mut in_trade = false; + let mut trade_entry_bar = 0_i64; + let mut trade_dir = 0.0_f64; + let mut trade_entry_price = 0.0_f64; + let mut trade_mae = 0.0_f64; + let mut trade_mfe = 0.0_f64; + + for i in 0..n { + let cur_pos = positions[i]; + + if !in_trade { + if cur_pos != 0.0 { + in_trade = true; + trade_entry_bar = i as i64; + trade_dir = cur_pos.signum(); + trade_entry_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 { + fill_prices[i] + } else { + high[i] + }; + trade_mae = 0.0; + trade_mfe = 0.0; + } + } else { + if trade_entry_price > 0.0 { + let unreal_high = + trade_dir * (high[i] - trade_entry_price) / trade_entry_price; + let unreal_low = + trade_dir * (low[i] - trade_entry_price) / trade_entry_price; + let bar_best = unreal_high.max(unreal_low); + let bar_worst = unreal_high.min(unreal_low); + if bar_best > trade_mfe { + trade_mfe = bar_best; + } + if bar_worst < trade_mae { + trade_mae = bar_worst; + } + } + + let pos_closed = cur_pos == 0.0 || cur_pos.signum() != trade_dir; + + if pos_closed { + let exit_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 { + fill_prices[i] + } else { + low[i] + }; + let pnl = if trade_entry_price > 0.0 { + trade_dir * (exit_price - trade_entry_price) / trade_entry_price + } else { + 0.0 + }; + + trades.push(TradeRecord { + entry_bar: trade_entry_bar, + exit_bar: i as i64, + direction: trade_dir, + entry_price: trade_entry_price, + exit_price, + pnl_pct: pnl, + duration_bars: i as i64 - trade_entry_bar, + mae: trade_mae, + mfe: trade_mfe, + }); + + if cur_pos != 0.0 { + in_trade = true; + trade_entry_bar = i as i64; + trade_dir = cur_pos.signum(); + trade_entry_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 { + fill_prices[i] + } else { + high[i] + }; + trade_mae = 0.0; + trade_mfe = 0.0; + } else { + in_trade = false; + } + } + } + } + + // Close any open trade at last bar + if in_trade { + let last = n - 1; + let exit_price = if fill_prices[last].is_finite() && fill_prices[last] > 0.0 { + fill_prices[last] + } else { + high[last] + }; + let pnl = if trade_entry_price > 0.0 { + trade_dir * (exit_price - trade_entry_price) / trade_entry_price + } else { + 0.0 + }; + trades.push(TradeRecord { + entry_bar: trade_entry_bar, + exit_bar: last as i64, + direction: trade_dir, + entry_price: trade_entry_price, + exit_price, + pnl_pct: pnl, + duration_bars: last as i64 - trade_entry_bar, + mae: trade_mae, + mfe: trade_mfe, + }); + } + + Ok(trades) +} + +// --------------------------------------------------------------------------- +// Multi-asset backtest +// --------------------------------------------------------------------------- + +/// Multi-asset result: per-asset strategy returns (n_bars x n_assets), portfolio returns, portfolio equity. +#[derive(Clone, Debug)] +pub struct MultiAssetBacktestResult { + /// Shape: (n_assets, n_bars) — row-major per asset. + pub asset_returns: Vec>, + pub portfolio_returns: Vec, + pub portfolio_equity: Vec, +} + +/// Backtest N assets, then combine into a portfolio. +/// +/// `close_2d`: row-major (n_assets, n_bars) +/// `weights_2d`: row-major (n_assets, n_bars) +/// +/// Callers must transpose from (n_bars, n_assets) if needed. +pub fn backtest_multi_asset_core( + close_2d: &[Vec], + weights_2d: &[Vec], + n_bars: usize, + n_assets: usize, + commission_per_trade: f64, + slippage_bps: f64, + max_asset_weight: f64, + max_gross_exposure: f64, + max_net_exposure: f64, +) -> Result { + if n_bars < 2 { + return Err("n_bars must be at least 2".to_string()); + } + if close_2d.len() != n_assets || weights_2d.len() != n_assets { + return Err("close_2d and weights_2d must have n_assets rows".to_string()); + } + + // Apply portfolio constraints per bar + let mut constrained: Vec> = weights_2d.to_vec(); + if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 { + for i in 0..n_bars { + // 1. Clamp per-asset weight + if max_asset_weight < f64::INFINITY && max_asset_weight > 0.0 { + for j in 0..n_assets { + let w = constrained[j][i]; + if w.abs() > max_asset_weight { + constrained[j][i] = w.signum() * max_asset_weight; + } + } + } + // 2. Normalize so sum(abs) <= max_gross_exposure + if max_gross_exposure > 0.0 { + let gross: f64 = (0..n_assets).map(|j| constrained[j][i].abs()).sum(); + if gross > max_gross_exposure { + let scale = max_gross_exposure / gross; + for j in 0..n_assets { + constrained[j][i] *= scale; + } + } + } + // 3. Clamp net exposure + if max_net_exposure > 0.0 { + let net: f64 = (0..n_assets).map(|j| constrained[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 { + constrained[j][i] -= adj_per_asset; + } + } + } + } + } + + // Per-asset backtests + let asset_strategy_returns: Vec> = (0..n_assets) + .map(|j| { + let (_, strat_rets, _) = + single_asset_backtest(&close_2d[j], &constrained[j], commission_per_trade, slippage_bps); + strat_rets + }) + .collect(); + + // Portfolio return = sum of per-asset strategy 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_strategy_returns[j][i]; + } + 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(MultiAssetBacktestResult { + asset_returns: asset_strategy_returns, + portfolio_returns, + portfolio_equity, + }) +} + +// --------------------------------------------------------------------------- +// Monte Carlo bootstrap +// --------------------------------------------------------------------------- + +/// Bootstrap Monte Carlo simulation over strategy returns. +/// +/// Returns `n_sims` equity curves, each of length `n_bars`. +pub fn monte_carlo_bootstrap( + strategy_returns: &[f64], + n_sims: usize, + seed: u64, + block_size: usize, +) -> Result>, String> { + let n = strategy_returns.len(); + if n < 2 { + return Err("strategy_returns must have at least 2 elements".to_string()); + } + if n_sims == 0 { + return Err("n_sims must be >= 1".to_string()); + } + let bsize = block_size.max(1).min(n); + + let mut result: Vec> = Vec::with_capacity(n_sims); + + for sim_idx in 0..n_sims { + 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)); + lcg_next(&mut state); + lcg_next(&mut state); + + let mut row = vec![0.0_f64; n]; + + if bsize == 1 { + for dst in row.iter_mut() { + *dst = strategy_returns[lcg_index(&mut state, n)]; + } + } else { + let mut filled = 0_usize; + while filled < n { + let start = lcg_index(&mut state, n); + let take = bsize.min(n - filled); + for k in 0..take { + row[filled + k] = strategy_returns[(start + k) % n]; + } + filled += take; + } + } + + // Convert to equity curve in-place + let mut cum = 1.0_f64; + for elem in row.iter_mut() { + cum *= 1.0 + *elem; + *elem = cum; + } + + result.push(row); + } + + Ok(result) +} + +// --------------------------------------------------------------------------- +// Walk-forward indices +// --------------------------------------------------------------------------- + +/// Generate train/test fold index boundaries for walk-forward analysis. +/// +/// Returns a vector of (train_start, train_end, test_start, test_end) tuples. +pub fn walk_forward_indices( + n_bars: usize, + train_bars: usize, + test_bars: usize, + anchored: bool, + step_bars: usize, +) -> Result, String> { + if train_bars == 0 { + return Err("train_bars must be >= 1".to_string()); + } + if test_bars == 0 { + return Err("test_bars must be >= 1".to_string()); + } + if train_bars + test_bars > n_bars { + return Err("train_bars + test_bars must be <= n_bars".to_string()); + } + + let step = if step_bars == 0 { test_bars } else { step_bars }; + let mut folds: Vec<[i64; 4]> = Vec::new(); + + let mut offset = 0_usize; + loop { + let train_start = if anchored { 0 } else { offset }; + let train_end = offset + train_bars; + let test_start = train_end; + let test_end = test_start + test_bars; + + if test_end > n_bars { + break; + } + + folds.push([ + train_start as i64, + train_end as i64, + test_start as i64, + test_end as i64, + ]); + + offset += step; + } + + if folds.is_empty() { + return Err("No complete folds fit within n_bars with the given train/test sizes".to_string()); + } + + Ok(folds) +} + +// --------------------------------------------------------------------------- +// Kelly criterion +// --------------------------------------------------------------------------- + +/// Compute the Kelly fraction: f = win_rate - (1 - win_rate) * (|avg_loss| / avg_win), clamped to [0, 1]. +pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> Result { + if !(0.0..=1.0).contains(&win_rate) { + return Err("win_rate must be in [0, 1]".to_string()); + } + if avg_win <= 0.0 { + return Err("avg_win must be > 0".to_string()); + } + Ok(kelly_formula(win_rate, avg_win, avg_loss)) +} + +/// Half-Kelly fraction (conservative position sizing). +pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> Result { + Ok(kelly_fraction(win_rate, avg_win, avg_loss)? / 2.0) +} + +// --------------------------------------------------------------------------- +// StreamingBacktest +// --------------------------------------------------------------------------- + +impl StreamingBacktest { + pub fn new(commission_per_trade: f64, slippage_bps: f64) -> Self { + StreamingBacktest { + commission_per_trade, + slippage_bps, + position: 0.0, + entry_price: f64::NAN, + equity: 1.0, + prev_close: f64::NAN, + total_commission: 0.0, + n_trades: 0, + sum_wins: 0.0, + n_wins: 0, + sum_losses: 0.0, + n_losses: 0, + } + } + + /// Process one bar. Returns position, bar_return, equity, n_trades. + pub fn on_bar(&mut self, close: f64, signal: f64) -> StreamingBarResult { + let slip = self.slippage_bps / 10_000.0; + let mut bar_return = 0.0_f64; + + if self.position != 0.0 && !self.prev_close.is_nan() { + let price_ret = (close - self.prev_close) / self.prev_close; + bar_return = self.position * price_ret; + self.equity *= 1.0 + bar_return; + } + + let new_pos = if signal.is_nan() { 0.0 } else { signal }; + if (new_pos - self.position).abs() > 1e-12 { + let direction = if new_pos > self.position { 1.0 } else { -1.0 }; + let slippage_cost = direction * slip; + self.equity *= 1.0 - slippage_cost.abs(); + self.equity -= self.commission_per_trade; + self.total_commission += self.commission_per_trade; + + if self.position != 0.0 && !self.entry_price.is_nan() { + let trade_ret = self.position * (close - self.entry_price) / self.entry_price; + if trade_ret >= 0.0 { + self.sum_wins += trade_ret; + self.n_wins += 1; + } else { + self.sum_losses += trade_ret.abs(); + self.n_losses += 1; + } + self.n_trades += 1; + } + + self.position = new_pos; + self.entry_price = if new_pos != 0.0 { close } else { f64::NAN }; + } + + self.prev_close = close; + + StreamingBarResult { + position: self.position, + bar_return, + equity: self.equity, + n_trades: self.n_trades, + } + } + + /// Summary statistics. + pub fn summary(&self) -> StreamingSummary { + let win_rate = if self.n_trades > 0 { + self.n_wins as f64 / self.n_trades as f64 + } else { + 0.0 + }; + let avg_win = if self.n_wins > 0 { + self.sum_wins / self.n_wins as f64 + } else { + 0.0 + }; + let avg_loss = if self.n_losses > 0 { + self.sum_losses / self.n_losses as f64 + } else { + 0.0 + }; + let kf = kelly_formula(win_rate, avg_win, avg_loss); + + StreamingSummary { + equity: self.equity, + n_trades: self.n_trades, + total_commission: self.total_commission, + win_rate, + avg_win, + avg_loss, + kelly_fraction: kf, + } + } + + /// Reset all state. + pub fn reset(&mut self) { + self.position = 0.0; + self.entry_price = f64::NAN; + self.equity = 1.0; + self.prev_close = f64::NAN; + self.total_commission = 0.0; + self.n_trades = 0; + self.sum_wins = 0.0; + self.n_wins = 0; + self.sum_losses = 0.0; + self.n_losses = 0; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nan_to_num() { + assert_eq!(nan_to_num(f64::NAN), 0.0); + assert_eq!(nan_to_num(f64::INFINITY), f64::MAX); + assert_eq!(nan_to_num(f64::NEG_INFINITY), -f64::MAX); + assert_eq!(nan_to_num(42.0), 42.0); + } + + #[test] + fn test_kelly_formula_basic() { + let f = kelly_formula(0.6, 1.0, 0.5); + assert!((f - 0.4).abs() < 1e-10); + } + + #[test] + fn test_kelly_fraction_validation() { + assert!(kelly_fraction(1.5, 1.0, 0.5).is_err()); + assert!(kelly_fraction(0.5, -1.0, 0.5).is_err()); + assert!(kelly_fraction(0.6, 1.0, 0.5).is_ok()); + } + + #[test] + fn test_half_kelly() { + let full = kelly_fraction(0.6, 1.0, 0.5).unwrap(); + let half = half_kelly_fraction(0.6, 1.0, 0.5).unwrap(); + assert!((half - full / 2.0).abs() < 1e-12); + } + + #[test] + fn test_backtest_core_flat_signal() { + let close = vec![100.0, 101.0, 102.0, 103.0, 104.0]; + let signals = vec![0.0, 0.0, 0.0, 0.0, 0.0]; + let result = backtest_core(&close, &signals, None, 0.0, 100_000.0, 0.0).unwrap(); + // With zero signals, equity should remain at 1.0 + for &e in &result.equity { + assert!((e - 1.0).abs() < 1e-10); + } + } + + #[test] + fn test_backtest_core_long_signal() { + let close = vec![100.0, 110.0, 120.0]; + let signals = vec![1.0, 1.0, 1.0]; + let result = backtest_core(&close, &signals, None, 0.0, 100_000.0, 0.0).unwrap(); + // Position is lagged: pos[0]=0, pos[1]=1, pos[2]=1 + // bar_returns[1] = 0.1, bar_returns[2] ≈ 0.0909 + // strategy_returns[1] = 1*0.1 = 0.1, strategy_returns[2] = 1*0.0909 + assert!((result.equity[2] - 1.1 * (1.0 + 10.0 / 110.0)).abs() < 1e-10); + } + + #[test] + fn test_walk_forward_indices_basic() { + let folds = walk_forward_indices(100, 50, 25, false, 0).unwrap(); + assert_eq!(folds.len(), 2); + assert_eq!(folds[0], [0, 50, 50, 75]); + assert_eq!(folds[1], [25, 75, 75, 100]); + } + + #[test] + fn test_walk_forward_anchored() { + let folds = walk_forward_indices(100, 50, 25, true, 0).unwrap(); + assert!(folds.len() >= 2); + // Anchored: train always starts at 0 + for fold in &folds { + assert_eq!(fold[0], 0); + } + } + + #[test] + fn test_monte_carlo_basic() { + let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015]; + let result = monte_carlo_bootstrap(&returns, 10, 42, 1).unwrap(); + assert_eq!(result.len(), 10); + for curve in &result { + assert_eq!(curve.len(), 5); + // Equity curves should be positive + assert!(curve.last().unwrap() > &0.0); + } + } + + #[test] + fn test_extract_trades_empty() { + let positions = vec![0.0, 0.0, 0.0]; + let fill_prices = vec![f64::NAN, f64::NAN, f64::NAN]; + let high = vec![100.0, 101.0, 102.0]; + let low = vec![99.0, 100.0, 101.0]; + let trades = extract_trades_ohlcv(&positions, &fill_prices, &high, &low).unwrap(); + assert!(trades.is_empty()); + } + + #[test] + fn test_extract_trades_single_roundtrip() { + let positions = vec![0.0, 1.0, 1.0, 0.0]; + let fill_prices = vec![f64::NAN, 100.0, f64::NAN, 110.0]; + let high = vec![100.0, 105.0, 115.0, 112.0]; + let low = vec![98.0, 99.0, 100.0, 108.0]; + let trades = extract_trades_ohlcv(&positions, &fill_prices, &high, &low).unwrap(); + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].entry_bar, 1); + assert_eq!(trades[0].exit_bar, 3); + assert!((trades[0].entry_price - 100.0).abs() < 1e-10); + assert!((trades[0].exit_price - 110.0).abs() < 1e-10); + assert!(trades[0].pnl_pct > 0.0); + } + + #[test] + fn test_ohlcv_backtest_basic() { + let n = 10; + let open: Vec = (0..n).map(|i| 100.0 + i as f64).collect(); + let high: Vec = open.iter().map(|&v| v + 2.0).collect(); + let low: Vec = open.iter().map(|&v| v - 2.0).collect(); + let close: Vec = open.iter().map(|&v| v + 1.0).collect(); + let signals: Vec = vec![0.0, 1.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 0.0]; + + let config = BacktestConfig::default(); + let result = backtest_ohlcv_core(&open, &high, &low, &close, &signals, &config, None).unwrap(); + assert_eq!(result.equity.len(), n); + // Equity should be positive + assert!(*result.equity.last().unwrap() > 0.0); + } + + #[test] + fn test_streaming_backtest() { + let mut engine = StreamingBacktest::new(0.0, 0.0); + let closes = vec![100.0, 105.0, 103.0, 110.0]; + let signals = vec![1.0, 1.0, -1.0, 0.0]; + + for (&c, &s) in closes.iter().zip(signals.iter()) { + let _r = engine.on_bar(c, s); + } + assert!(engine.equity > 0.0); + let summary = engine.summary(); + assert!(summary.n_trades > 0); + } + + #[test] + fn test_compute_performance_metrics_basic() { + let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015, 0.005, -0.003, 0.008]; + let mut equity = vec![1.0_f64; returns.len()]; + let mut cum = 1.0; + for (i, &r) in returns.iter().enumerate() { + cum *= 1.0 + r; + equity[i] = cum; + } + let metrics = compute_performance_metrics(&returns, &equity, 252.0, 0.0, None).unwrap(); + assert!(metrics.total_return > 0.0); + assert!(metrics.sharpe != 0.0); + assert!(metrics.n_trades > 0); + } + + #[test] + fn test_multi_asset_basic() { + let n_bars = 5; + let close1 = vec![100.0, 101.0, 102.0, 103.0, 104.0]; + let close2 = vec![200.0, 198.0, 201.0, 203.0, 205.0]; + let weights1 = vec![0.0, 0.5, 0.5, 0.5, 0.0]; + let weights2 = vec![0.0, 0.5, 0.5, 0.5, 0.0]; + + let result = backtest_multi_asset_core( + &[close1, close2], + &[weights1, weights2], + n_bars, + 2, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + ) + .unwrap(); + + assert_eq!(result.portfolio_returns.len(), n_bars); + assert_eq!(result.portfolio_equity.len(), n_bars); + assert_eq!(result.asset_returns.len(), 2); + } + + #[test] + fn test_sma_crossover_signals() { + let close: Vec = (1..=40).map(|i| i as f64).collect(); + let signals = sma_crossover_signals(&close, 5, 10).unwrap(); + assert_eq!(signals.len(), close.len()); + // First 9 bars should be NaN (slow SMA warm-up) + for i in 0..9 { + assert!(signals[i].is_nan(), "bar {} should be NaN", i); + } + } + + #[test] + fn test_sma_crossover_invalid() { + let close = vec![1.0; 20]; + assert!(sma_crossover_signals(&close, 10, 5).is_err()); + } + + #[test] + fn test_rsi_threshold_signals() { + let close: Vec = (1..=30).map(|i| 100.0 + (i as f64).sin() * 10.0).collect(); + let signals = rsi_threshold_signals(&close, 14, 30.0, 70.0); + assert_eq!(signals.len(), close.len()); + } +} diff --git a/crates/ferro_ta_core/src/batch.rs b/crates/ferro_ta_core/src/batch.rs new file mode 100644 index 0000000..21d55c4 --- /dev/null +++ b/crates/ferro_ta_core/src/batch.rs @@ -0,0 +1,642 @@ +//! 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]) -> Result { + 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], + low: &[Vec], + close: &[Vec], +) -> 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(prices: &[f64], timeperiod: usize, mut map: F) -> Vec +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::(); + let mut sum_xy = prices[..timeperiod] + .iter() + .enumerate() + .map(|(idx, &value)| idx as f64 * value) + .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 +} + +// --------------------------------------------------------------------------- +// CCI / WILLR helpers (no external dep) +// --------------------------------------------------------------------------- + +fn compute_cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let typical_price: Vec = 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::() / timeperiod as f64; + let mad = window + .iter() + .map(|&value| (value - mean).abs()) + .sum::() + / 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 { + 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], timeperiod: usize) -> Result>, 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], timeperiod: usize) -> Result>, 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], timeperiod: usize) -> Result>, 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], + low: &[Vec], + close: &[Vec], + timeperiod: usize, +) -> Result>, 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)`. +pub fn batch_stoch( + high: &[Vec], + low: &[Vec], + close: &[Vec], + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, +) -> Result<(Vec>, Vec>), 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], + low: &[Vec], + close: &[Vec], + timeperiod: usize, +) -> Result>, 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, 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, String>>` — one result per (name, timeperiod) pair. +pub fn run_close_indicators( + close: &[f64], + names: &[String], + timeperiods: &[usize], +) -> Result>, 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, 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>, 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 { + 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, Vec, Vec) { + let close = close_data(); + let high: Vec = close.iter().map(|c| c + 0.5).collect(); + let low: Vec = 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![]; + 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); + } +} diff --git a/crates/ferro_ta_core/src/chunked.rs b/crates/ferro_ta_core/src/chunked.rs new file mode 100644 index 0000000..9743626 --- /dev/null +++ b/crates/ferro_ta_core/src/chunked.rs @@ -0,0 +1,123 @@ +//! 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 { + 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 { + 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 { + if chunk_size == 0 || n == 0 { + return vec![]; + } + let mut ranges: Vec = 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 { + 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()); + } +} diff --git a/crates/ferro_ta_core/src/commission.rs b/crates/ferro_ta_core/src/commission.rs new file mode 100644 index 0000000..5003e73 --- /dev/null +++ b/crates/ferro_ta_core/src/commission.rs @@ -0,0 +1,295 @@ +//! 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 { + serde_json::to_string_pretty(self) + } + + /// Deserialize from a JSON string. + #[cfg(feature = "serde")] + pub fn from_json(s: &str) -> Result { + serde_json::from_str(s) + } +} diff --git a/crates/ferro_ta_core/src/crypto.rs b/crates/ferro_ta_core/src/crypto.rs new file mode 100644 index 0000000..f5cbb4c --- /dev/null +++ b/crates/ferro_ta_core/src/crypto.rs @@ -0,0 +1,91 @@ +//! 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 { + 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 { + (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 { + 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()); + } +} diff --git a/crates/ferro_ta_core/src/currency.rs b/crates/ferro_ta_core/src/currency.rs new file mode 100644 index 0000000..6343786 --- /dev/null +++ b/crates/ferro_ta_core/src/currency.rs @@ -0,0 +1,173 @@ +//! 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 = rest.chars().collect(); + let first_len = chars.len() % 2; + if first_len > 0 { + out.push_str(&chars[..first_len].iter().collect::()); + } + let mut i = first_len; + while i < chars.len() { + if !out.is_empty() { + out.push(','); + } + out.push_str(&chars[i..i + 2].iter().collect::()); + 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); + } +} diff --git a/crates/ferro_ta_core/src/cycle.rs b/crates/ferro_ta_core/src/cycle.rs new file mode 100644 index 0000000..fd5c845 --- /dev/null +++ b/crates/ferro_ta_core/src/cycle.rs @@ -0,0 +1,370 @@ +//! 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, + pub dc_period: Vec, + pub dc_phase: Vec, + pub inphase: Vec, + pub quadrature: Vec, + pub trend_mode: Vec, +} + +/// 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 { + compute_ht_core(close).trendline +} + +/// Hilbert Transform Dominant Cycle Period in bars. +pub fn ht_dcperiod(close: &[f64]) -> Vec { + compute_ht_core(close).dc_period +} + +/// Hilbert Transform Dominant Cycle Phase in degrees. +pub fn ht_dcphase(close: &[f64]) -> Vec { + compute_ht_core(close).dc_phase +} + +/// Hilbert Transform Phasor components. Returns `(inphase, quadrature)`. +pub fn ht_phasor(close: &[f64]) -> (Vec, Vec) { + 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, Vec) { + 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 { + 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 { + (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 { + 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 + ); + } + } +} diff --git a/crates/ferro_ta_core/src/extended.rs b/crates/ferro_ta_core/src/extended.rs new file mode 100644 index 0000000..26d7014 --- /dev/null +++ b/crates/ferro_ta_core/src/extended.rs @@ -0,0 +1,977 @@ +//! 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` (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 { + 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` 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 { + 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` with `NaN` for the first `timeperiod - 1` entries. +pub fn vwma(close: &[f64], volume: &[f64], timeperiod: usize) -> Vec { + 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, Vec) { + 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 { + if close[i] > upper_band[i] { + 1 + } else { + -1 + } + } else if 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, Vec, Vec) { + 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 { + 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, Vec, Vec) { + 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 { + 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, Vec) { + 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. +pub fn ichimoku( + high: &[f64], + low: &[f64], + close: &[f64], + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, +) -> (Vec, Vec, Vec, Vec, Vec) { + 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 { + 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). +pub fn pivot_points( + high: &[f64], + low: &[f64], + close: &[f64], + method: &str, +) -> (Vec, Vec, Vec, Vec, Vec) { + 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, Vec, Vec, Vec) { + 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 = (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 = (0..n).map(|i| 100.0 + i as f64 + 1.0).collect(); + let low: Vec = (0..n).map(|i| 100.0 + i as f64 - 1.0).collect(); + let close: Vec = (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()); + } +} diff --git a/crates/ferro_ta_core/src/lib.rs b/crates/ferro_ta_core/src/lib.rs index 9ee0cd5..b5ddd96 100644 --- a/crates/ferro_ta_core/src/lib.rs +++ b/crates/ferro_ta_core/src/lib.rs @@ -26,11 +26,30 @@ 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; diff --git a/crates/ferro_ta_core/src/math.rs b/crates/ferro_ta_core/src/math.rs index 4bdf919..bd08961 100644 --- a/crates/ferro_ta_core/src/math.rs +++ b/crates/ferro_ta_core/src/math.rs @@ -2,7 +2,14 @@ use std::collections::VecDeque; -/// Rolling sum over `timeperiod` bars. +/// Compute the rolling sum over `timeperiod` bars. +/// +/// Returns a `Vec` 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). pub fn sum(real: &[f64], timeperiod: usize) -> Vec { let n = real.len(); let mut result = vec![f64::NAN; n]; @@ -18,20 +25,38 @@ pub fn sum(real: &[f64], timeperiod: usize) -> Vec { result } -/// Rolling maximum over `timeperiod` bars — O(n) via monotonic deque. +/// 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). pub fn max(real: &[f64], timeperiod: usize) -> Vec { sliding_max(real, timeperiod) } -/// Rolling minimum over `timeperiod` bars — O(n) via monotonic deque. +/// 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). pub fn min(real: &[f64], timeperiod: usize) -> Vec { sliding_min(real, timeperiod) } -/// Sliding maximum over `timeperiod` bars — O(n) via monotonic deque. +/// Compute the sliding maximum over `timeperiod` bars in O(n) time. /// -/// Equivalent to `max` but uses a monotonic deque for O(n) total time. -/// Leading `timeperiod - 1` values are NaN. +/// 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). pub fn sliding_max(real: &[f64], timeperiod: usize) -> Vec { let n = real.len(); let mut result = vec![f64::NAN; n]; @@ -56,10 +81,14 @@ pub fn sliding_max(real: &[f64], timeperiod: usize) -> Vec { result } -/// Sliding minimum over `timeperiod` bars — O(n) via monotonic deque. +/// Compute the sliding minimum over `timeperiod` bars in O(n) time. /// -/// Equivalent to `min` but uses a monotonic deque for O(n) total time. -/// Leading `timeperiod - 1` values are NaN. +/// 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). pub fn sliding_min(real: &[f64], timeperiod: usize) -> Vec { let n = real.len(); let mut result = vec![f64::NAN; n]; diff --git a/crates/ferro_ta_core/src/math_ops.rs b/crates/ferro_ta_core/src/math_ops.rs new file mode 100644 index 0000000..99800e7 --- /dev/null +++ b/crates/ferro_ta_core/src/math_ops.rs @@ -0,0 +1,154 @@ +//! 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 { + 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 { + 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 { + 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 { + let n = real.len(); + let mut result = vec![-1i64; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let mut dq: VecDeque = 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 { + let n = real.len(); + let mut result = vec![-1i64; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let mut dq: VecDeque = 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())); + } +} diff --git a/crates/ferro_ta_core/src/momentum.rs b/crates/ferro_ta_core/src/momentum.rs index cc3e733..113c178 100644 --- a/crates/ferro_ta_core/src/momentum.rs +++ b/crates/ferro_ta_core/src/momentum.rs @@ -1,11 +1,15 @@ //! Momentum indicators. -use crate::math::{sliding_max, sliding_min}; -/// Relative Strength Index — TA-Lib compatible Wilder smoothing. +/// Compute the Relative Strength Index (RSI). /// -/// 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)`. +/// 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). pub fn rsi(close: &[f64], timeperiod: usize) -> Vec { let n = close.len(); let mut result = vec![f64::NAN; n]; @@ -46,7 +50,14 @@ pub fn rsi(close: &[f64], timeperiod: usize) -> Vec { result } -/// Momentum — `close[i] - close[i - timeperiod]`. +/// Compute the Momentum indicator: `close[i] - close[i - timeperiod]`. +/// +/// Returns a `Vec` 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). pub fn mom(close: &[f64], timeperiod: usize) -> Vec { let n = close.len(); let mut result = vec![f64::NAN; n]; @@ -59,14 +70,21 @@ pub fn mom(close: &[f64], timeperiod: usize) -> Vec { result } -/// Stochastic Oscillator — TA-Lib compatible. +/// Compute the Stochastic Oscillator (TA-Lib compatible). /// -/// Returns `(slowk, slowd)`. -/// - Fast %K[i] = 100 * (close[i] - min(low, fastk_period)) / (max(high, fastk_period) - min(low, fastk_period)) -/// - Slow %K = SMA(fast %K, slowk_period) -/// - Slow %D = SMA(slow %K, slowd_period) +/// 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`) /// -/// Uses O(n) sliding max/min via monotonic deques. +/// 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. pub fn stoch( high: &[f64], low: &[f64], @@ -84,29 +102,38 @@ 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]; - // Fast %K is valid from index fastk_period-1 onward. + // Fused pass: compute fast %K inline with sliding max/min. + // For typical small windows (5-14), inline scan beats VecDeque overhead. let fastk_start = fastk_period - 1; - let mut fastk_valid = vec![0.0; n - fastk_start]; + let fk_len = n - fastk_start; + let mut fastk_valid = vec![0.0_f64; fk_len]; + for i in fastk_start..n { - let range = max_h[i] - min_l[i]; + // 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; fastk_valid[i - fastk_start] = if range != 0.0 { - 100.0 * (close[i] - min_l[i]) / range + 100.0 * (close[i] - ll) / range } else { 0.0 }; } - // Slow %K = SMA(fastk_valid, slowk_period); write directly into `slowk` offset by `fastk_start`. + // Slow %K = SMA(fastk_valid, slowk_period). 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; @@ -249,50 +276,164 @@ fn adx_inner(high: &[f64], low: &[f64], close: &[f64], period: usize) -> AdxInne (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx) } -/// Plus Directional Movement (Wilder smoothed). Output length = n (bar 0 is NaN). -pub fn plus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { +/// 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, Vec) { let n = high.len(); - let closes = vec![0.0_f64; n]; - let (pdm, _, _, _, _, _) = adx_inner(high, low, &closes, timeperiod); + 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::(); + let mut mdm_s = mdm[..period].iter().sum::(); + + 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` of length `n`; +/// the first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` - High and low price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn plus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { + let (pdm, _) = dm_only_inner(high, low, timeperiod); pdm } -/// Minus Directional Movement (Wilder smoothed). Output length = n (bar 0 is NaN). +/// Compute the Minus Directional Movement (-DM), Wilder smoothed. +/// +/// Measures downward price movement. Returns a `Vec` of length `n`; +/// the first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` - High and low price series (same length). +/// * `timeperiod` - Wilder smoothing period. pub fn minus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { - let n = high.len(); - let closes = vec![0.0_f64; n]; - let (_, mdm, _, _, _, _) = adx_inner(high, low, &closes, timeperiod); + let (_, mdm) = dm_only_inner(high, low, timeperiod); mdm } -/// Plus Directional Indicator (Wilder smoothed). Output length = n. +/// 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. pub fn plus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { let (_, _, pdi, _, _, _) = adx_inner(high, low, close, timeperiod); pdi } -/// Minus Directional Indicator (Wilder smoothed). Output length = n. +/// 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. pub fn minus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { let (_, _, _, mdi, _, _) = adx_inner(high, low, close, timeperiod); mdi } -/// Directional Movement Index: 100 * |+DI − −DI| / (+DI + −DI). +/// 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. pub fn dx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { let (_, _, _, _, dx_vals, _) = adx_inner(high, low, close, timeperiod); dx_vals } -/// Average Directional Movement Index (Wilder smoothing of DX). +/// 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). pub fn adx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod); adx_vals } -/// ADX Rating: (ADX[i] + ADX[i − timeperiod]) / 2. +/// 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). pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { let n = high.len(); - let adx_vals = adx(high, low, close, timeperiod); + // Reuse adx_all to compute ADX once, then derive ADXR from it + let (_, _, _, _, _, adx_vals) = adx_inner(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() { diff --git a/crates/ferro_ta_core/src/overlap.rs b/crates/ferro_ta_core/src/overlap.rs index 33ee0e2..3d88158 100644 --- a/crates/ferro_ta_core/src/overlap.rs +++ b/crates/ferro_ta_core/src/overlap.rs @@ -3,7 +3,14 @@ //! All functions return a `Vec` of the same length as the input. //! Leading values are `f64::NAN` for the warm-up period. -/// Simple Moving Average over `timeperiod` bars. +/// Compute the Simple Moving Average (SMA) over a rolling window. +/// +/// Returns a `Vec` 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). /// /// # Edge Cases /// Returns all-NaN when `timeperiod < 1` or `close.len() < timeperiod`. @@ -14,8 +21,17 @@ pub fn sma(close: &[f64], timeperiod: usize) -> Vec { result } -/// Simple Moving Average written directly into `dest` starting at `dest_offset`. -/// Leaves values before `dest_offset + timeperiod - 1` untouched (e.g. they can be NaN). +/// 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. pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: usize) { let n = src.len(); if timeperiod < 1 || n < timeperiod { @@ -66,7 +82,15 @@ pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: u } } -/// Exponential Moving Average — seeded with SMA of first `timeperiod` bars. +/// 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` +/// of the same length as `close`; the first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - Lookback period (must be >= 1). pub fn ema(close: &[f64], timeperiod: usize) -> Vec { let n = close.len(); let mut result = vec![f64::NAN; n]; @@ -82,10 +106,15 @@ pub fn ema(close: &[f64], timeperiod: usize) -> Vec { result } -/// Weighted Moving Average — O(n) incremental algorithm using running weighted sum. +/// Compute the Weighted Moving Average (WMA). /// -/// Recurrence: `T[i] = T[i-1] + n*close[i] - S[i-1]` -/// where `S[i]` is the rolling sum over `timeperiod` bars. +/// 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` of length `n`; the first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - Rolling window size (must be >= 1). pub fn wma(close: &[f64], timeperiod: usize) -> Vec { let n = close.len(); let mut result = vec![f64::NAN; n]; @@ -157,10 +186,43 @@ pub fn wma(close: &[f64], timeperiod: usize) -> Vec { result } -/// Bollinger Bands — returns `(upper, middle, lower)`. +/// Compute Bollinger Bands, returning `(upper, middle, lower)`. /// -/// Middle is SMA; bands are `± nbdev * stddev`. -/// Uses O(n) sliding `sum` and `sum_sq` windows for mean and variance. +/// 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` 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. pub fn bbands( close: &[f64], timeperiod: usize, @@ -177,90 +239,135 @@ pub fn bbands( let mut lower = vec![f64::NAN; n]; let p = timeperiod as f64; - // Seed sliding sums for the first window. - #[cfg(feature = "simd")] - let (mut sum, mut sum_sq) = { - use wide::f64x4; - let p_data = &close[..timeperiod]; - let mut sum_simd = f64x4::splat(0.0); - let mut sq_simd = f64x4::splat(0.0); - let mut chunks = p_data.chunks_exact(4); - for chunk in &mut chunks { - let vals = f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]); - sum_simd += vals; - sq_simd += vals * vals; - } - let s_arr = sum_simd.to_array(); - let sq_arr = sq_simd.to_array(); - let mut sum = s_arr[0] + s_arr[1] + s_arr[2] + s_arr[3]; - let mut sum_sq = sq_arr[0] + sq_arr[1] + sq_arr[2] + sq_arr[3]; - for &v in chunks.remainder() { - sum += v; - sum_sq += v * v; - } - (sum, sum_sq) - }; + // --- 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; + } - #[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 var = (m2 / p).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 { - 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; - + 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], + ); i += 2; } if i < n { - let old = close[i - timeperiod]; - sum += close[i] - old; - sum_sq += close[i] * close[i] - old * old; - let mean = sum / p; - let var = (sum_sq / p - mean * mean).max(0.0); - let std = var.sqrt(); - middle[i] = mean; - upper[i] = mean + nbdevup * std; - lower[i] = mean - nbdevdn * std; + welford_step( + close[i - timeperiod], + close[i], + &mut mean, + &mut m2, + p, + nbdevup, + nbdevdn, + &mut upper[i], + &mut middle[i], + &mut lower[i], + ); } + (upper, middle, lower) } -/// MACD — EMA(fastperiod) minus EMA(slowperiod), signal = EMA(macd, signalperiod). +/// Compute the Moving Average Convergence/Divergence (MACD). /// -/// Returns `(macd_line, signal_line, histogram)`, each of length `n`. -/// Leading values are `NaN` during warmup. -/// `fastperiod` must be less than `slowperiod`. +/// `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. /// -/// 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. +/// # 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` of length `n`. pub fn macd( close: &[f64], fastperiod: usize, @@ -383,6 +490,74 @@ 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 = (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::() / 20.0; + assert!( + (middle[i] - expected_mean).abs() < 1e-4, + "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 diff --git a/crates/ferro_ta_core/src/pattern.rs b/crates/ferro_ta_core/src/pattern.rs new file mode 100644 index 0000000..e50ba19 --- /dev/null +++ b/crates/ferro_ta_core/src/pattern.rs @@ -0,0 +1,1806 @@ +//! Candlestick pattern recognition — pure Rust implementations. +//! +//! Each function takes `(open, high, low, close)` as `&[f64]` slices and returns +//! `Vec` with values -100, 0, or 100 indicating bearish, neutral, or bullish +//! pattern signals respectively. + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Epsilon for doji-like candles (body ~ 0) to avoid division by zero. +pub const DOJI_BODY_EPSILON: f64 = 0.0001; + +#[inline] +pub fn body_size(open: f64, close: f64) -> f64 { + (close - open).abs() +} + +#[inline] +pub fn upper_shadow(open: f64, high: f64, close: f64) -> f64 { + high - open.max(close) +} + +#[inline] +pub fn lower_shadow(open: f64, low: f64, close: f64) -> f64 { + open.min(close) - low +} + +#[inline] +pub fn candle_range(high: f64, low: f64) -> f64 { + high - low +} + +#[inline] +pub fn is_bullish(open: f64, close: f64) -> bool { + close >= open +} + +#[inline] +pub fn is_bearish(open: f64, close: f64) -> bool { + close < open +} + +/// Validate that all four OHLC slices have the same length. Returns `Err` with +/// a descriptive message on mismatch. +pub fn validate_ohlc( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], +) -> Result { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(format!( + "OHLC length mismatch: open={}, high={}, low={}, close={}", + n, + high.len(), + low.len(), + close.len() + )); + } + Ok(n) +} + +// --------------------------------------------------------------------------- +// 61 candlestick pattern functions +// --------------------------------------------------------------------------- + +/// Two Crows (bearish) +pub fn cdl2crows(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, c1) = (open[i - 2], close[i - 2]); + let (o2, c2) = (open[i - 1], close[i - 1]); + let (o3, c3) = (open[i], close[i]); + if is_bullish(o1, c1) + && is_bearish(o2, c2) + && o2 > c1 + && c2 > c1 + && is_bearish(o3, c3) + && o3 < o2 + && o3 > c2 + && c3 > o1 + && c3 < c1 + { + result[i] = -100; + } + } + result +} + +/// Three Black Crows (bearish) +pub fn cdl3blackcrows(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, h2, l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + let range3 = candle_range(h3, l3); + + let long_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let long_body2 = range2 > 0.0 && body2 >= range2 * 0.5; + let long_body3 = range3 > 0.0 && body3 >= range3 * 0.5; + + let open2_in_body1 = o2 < o1 && o2 > c1; + let open3_in_body2 = o3 < o2 && o3 > c2; + + let small_upper1 = upper_shadow(o1, h1, c1) <= body1 * 0.3; + let small_upper2 = upper_shadow(o2, h2, c2) <= body2 * 0.3; + let small_upper3 = upper_shadow(o3, h3, c3) <= body3 * 0.3; + + if is_bearish(o1, c1) + && is_bearish(o2, c2) + && is_bearish(o3, c3) + && long_body1 + && long_body2 + && long_body3 + && open2_in_body1 + && open3_in_body2 + && small_upper1 + && small_upper2 + && small_upper3 + && c2 < c1 + && c3 < c2 + && l3 < l2 + && l2 < l1 + { + result[i] = -100; + } + } + result +} + +/// Three Inside Up/Down +pub fn cdl3inside(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let c3 = close[i]; + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let range1 = candle_range(h1, l1); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; + + let body2_high = o2.max(c2); + let body2_low = o2.min(c2); + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + let inside = body2_high <= body1_high && body2_low >= body1_low && body2 < body1 * 0.5; + + if is_bearish(o1, c1) && large_body1 && inside && is_bullish(o2, c2) && c3 > c2 { + result[i] = 100; + } else if is_bullish(o1, c1) && large_body1 && inside && is_bearish(o2, c2) && c3 < c2 { + result[i] = -100; + } + } + result +} + +/// Three-Line Strike +pub fn cdl3linestrike(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 3..n { + let (o0, c0) = (open[i - 3], close[i - 3]); + let (o1, c1) = (open[i - 2], close[i - 2]); + let (o2, c2) = (open[i - 1], close[i - 1]); + let (o3, c3) = (open[i], close[i]); + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && is_bearish(o2, c2) + && c1 < c0 + && c2 < c1 + && is_bullish(o3, c3) + && o3 < c2 + && c3 > o0 + { + result[i] = 100; + } else if is_bullish(o0, c0) + && is_bullish(o1, c1) + && is_bullish(o2, c2) + && c1 > c0 + && c2 > c1 + && is_bearish(o3, c3) + && o3 > c2 + && c3 < o0 + { + result[i] = -100; + } + } + result +} + +/// Three Outside Up/Down +pub fn cdl3outside(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, c1) = (open[i - 2], close[i - 2]); + let (o2, c2) = (open[i - 1], close[i - 1]); + let c3 = close[i]; + + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + let body2_high = o2.max(c2); + let body2_low = o2.min(c2); + let engulfs = body2_high > body1_high && body2_low < body1_low; + + if is_bearish(o1, c1) && is_bullish(o2, c2) && engulfs && c3 > c2 { + result[i] = 100; + } else if is_bullish(o1, c1) && is_bearish(o2, c2) && engulfs && c3 < c2 { + result[i] = -100; + } + } + result +} + +/// Three Stars In The South (bullish) +pub fn cdl3starsinsouth(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && is_bearish(o2, c2) + && h1 <= h0 + && l1 >= l0 + && h2 <= h1 + && l2 >= l1 + && body_size(o2, c2) <= body_size(o1, c1) * 0.6 + && upper_shadow(o2, h2, c2) <= body_size(o2, c2) * 0.2 + { + result[i] = 100; + } + } + result +} + +/// Three Advancing White Soldiers (bullish) +pub fn cdl3whitesoldiers(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, h2, l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + let range3 = candle_range(h3, l3); + + let long_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let long_body2 = range2 > 0.0 && body2 >= range2 * 0.5; + let long_body3 = range3 > 0.0 && body3 >= range3 * 0.5; + + let open2_in_body1 = o2 > o1 && o2 < c1; + let open3_in_body2 = o3 > o2 && o3 < c2; + + let small_lower1 = lower_shadow(o1, l1, c1) <= body1 * 0.3; + let small_lower2 = lower_shadow(o2, l2, c2) <= body2 * 0.3; + let small_lower3 = lower_shadow(o3, l3, c3) <= body3 * 0.3; + + if is_bullish(o1, c1) + && is_bullish(o2, c2) + && is_bullish(o3, c3) + && long_body1 + && long_body2 + && long_body3 + && open2_in_body1 + && open3_in_body2 + && small_lower1 + && small_lower2 + && small_lower3 + && c2 > c1 + && c3 > c2 + && h3 > h2 + && h2 > h1 + { + result[i] = 100; + } + } + result +} + +/// Abandoned Baby +pub fn cdlabandonedbaby(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let is_doji1 = range1 > 0.0 && body1 / range1 <= 0.1; + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_doji1 + && h1 < l0 + && is_bullish(o2, c2) + && range2 > 0.0 + && body2 >= range2 * 0.5 + && l2 > h1 + { + result[i] = 100; + } else if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_doji1 + && l1 > h0 + && is_bearish(o2, c2) + && range2 > 0.0 + && body2 >= range2 * 0.5 + && h2 < l1 + { + result[i] = -100; + } + } + result +} + +/// Advance Block (bearish) +pub fn cdladvanceblock(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, _l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let us0 = upper_shadow(o0, h0, c0); + let us1 = upper_shadow(o1, h1, c1); + let us2 = upper_shadow(o2, h2, c2); + if is_bullish(o0, c0) + && is_bullish(o1, c1) + && is_bullish(o2, c2) + && c1 > c0 + && c2 > c1 + && o1 >= o0 + && o1 <= c0 + && o2 >= o1 + && o2 <= c1 + && (body1 < body0 || body2 < body1 || us2 > us1 || us1 > us0) + { + result[i] = -100; + } + } + result +} + +/// Belt-hold +pub fn cdlbelthold(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + let body = body_size(o, c); + if range == 0.0 { + continue; + } + let long_body = body >= range * 0.6; + if is_bullish(o, c) && long_body && (o - l).abs() <= range * 0.01 { + result[i] = 100; + } else if is_bearish(o, c) && long_body && (h - o).abs() <= range * 0.01 { + result[i] = -100; + } + } + result +} + +/// Breakaway +pub fn cdlbreakaway(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 4..n { + let (o0, h0, l0, c0) = (open[i - 4], high[i - 4], low[i - 4], close[i - 4]); + let c1 = close[i - 3]; + let c2 = close[i - 2]; + let c3 = close[i - 1]; + let (o4, c4) = (open[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && c1 < l0 + && c2 < c1 + && c3 < c2 + && is_bullish(o4, c4) + && c4 > c1 + && c4 < c0 + { + result[i] = 100; + } else if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && c1 > h0 + && c2 > c1 + && c3 > c2 + && is_bearish(o4, c4) + && c4 < c1 + && c4 > c0 + { + result[i] = -100; + } + } + result +} + +/// Closing Marubozu +pub fn cdlclosingmarubozu(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + if body < range * 0.4 { + continue; + } + if is_bullish(o, c) && (h - c).abs() <= range * 0.01 { + result[i] = 100; + } else if is_bearish(o, c) && (c - l).abs() <= range * 0.01 { + result[i] = -100; + } + } + result +} + +/// Concealing Baby Swallow (bullish) +pub fn cdlconcealbabyswall(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 3..n { + let (o0, h0, l0, c0) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, h2, l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let maru0 = range0 > 0.0 + && upper_shadow(o0, h0, c0) <= range0 * 0.02 + && lower_shadow(o0, l0, c0) <= range0 * 0.02; + let maru1 = range1 > 0.0 + && upper_shadow(o1, h1, c1) <= range1 * 0.02 + && lower_shadow(o1, l1, c1) <= range1 * 0.02; + let gap_down = o2 < c1; + let shadow_into = h2 >= c1; + let engulfs = o3 >= o2 && c3 <= c2 && h3 >= h2 && l3 <= l2; + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && maru0 + && maru1 + && is_bearish(o2, c2) + && gap_down + && shadow_into + && is_bearish(o3, c3) + && engulfs + { + result[i] = 100; + } + } + result +} + +/// Counterattack +pub fn cdlcounterattack(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, h1, l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let long0 = range0 > 0.0 && body0 >= range0 * 0.5; + let long1 = range1 > 0.0 && body1 >= range1 * 0.5; + let same_close = (c1 - c0).abs() <= range0 * 0.02; + if is_bearish(o0, c0) && long0 && is_bullish(o1, c1) && long1 && same_close { + result[i] = 100; + } else if is_bullish(o0, c0) && long0 && is_bearish(o1, c1) && long1 && same_close { + result[i] = -100; + } + } + result +} + +/// Dark Cloud Cover (bearish) +pub fn cdldarkcloudcover(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let body0 = body_size(o0, c0); + let range0 = candle_range(h0, l0); + let midpoint0 = (o0 + c0) / 2.0; + if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_bearish(o1, c1) + && o1 > h0 + && c1 < midpoint0 + && c1 > o0 + { + result[i] = -100; + } + } + result +} + +/// Doji +pub fn cdldoji(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + if range > 0.0 && body / range <= 0.1 { + result[i] = 100; + } + } + result +} + +/// Doji Star +pub fn cdldojistar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + + let gap_down = o2.max(c2) < l1; + if is_bearish(o1, c1) && large_body1 && is_doji2 && gap_down { + result[i] = 100; + } + let gap_up = o2.min(c2) > h1; + if is_bullish(o1, c1) && large_body1 && is_doji2 && gap_up { + result[i] = -100; + } + } + result +} + +/// Dragonfly Doji (bullish) +pub fn cdldragonflydoji(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body / range <= 0.1 && us / range <= 0.1 && ls >= range * 0.6 { + result[i] = 100; + } + } + result +} + +/// Engulfing +pub fn cdlengulfing(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let prev_o = open[i - 1]; + let prev_c = close[i - 1]; + let curr_o = open[i]; + let curr_c = close[i]; + + let prev_body_high = prev_o.max(prev_c); + let prev_body_low = prev_o.min(prev_c); + let curr_body_high = curr_o.max(curr_c); + let curr_body_low = curr_o.min(curr_c); + + if is_bearish(prev_o, prev_c) + && is_bullish(curr_o, curr_c) + && curr_body_high > prev_body_high + && curr_body_low < prev_body_low + { + result[i] = 100; + } else if is_bullish(prev_o, prev_c) + && is_bearish(curr_o, curr_c) + && curr_body_high > prev_body_high + && curr_body_low < prev_body_low + { + result[i] = -100; + } + } + result +} + +/// Evening Doji Star (bearish) +pub fn cdleveningdojistar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range2 = candle_range(o2.min(c2) - DOJI_BODY_EPSILON, o2.max(c2)); + let range3 = candle_range(h3, l3); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; + + if is_bullish(o1, c1) + && large_body1 + && is_doji2 + && is_bearish(o3, c3) + && large_body3 + && c3 < (o1 + c1) / 2.0 + { + result[i] = -100; + } + } + result +} + +/// Evening Star (bearish) +pub fn cdleveningstar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range3 = candle_range(h3, l3); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let small_body2 = range1 > 0.0 && body2 < body1 * 0.3; + let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; + + if is_bullish(o1, c1) + && large_body1 + && small_body2 + && is_bearish(o3, c3) + && large_body3 + && c3 < (o1 + c1) / 2.0 + { + result[i] = -100; + } + } + result +} + +/// Up/Down-gap side-by-side white lines +pub fn cdlgapsidesidewhite(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, _h0, _l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let both_bullish = is_bullish(o1, c1) && is_bullish(o2, c2); + let similar_size = body1 > 0.0 && (body2 - body1).abs() / body1 <= 0.3; + let similar_open = body1 > 0.0 && (o2 - o1).abs() / body1 <= 0.3; + if is_bullish(o0, c0) && both_bullish && similar_size && similar_open && o1 > c0 { + result[i] = 100; + } else if is_bearish(o0, c0) && both_bullish && similar_size && similar_open && c1 < o0 { + result[i] = -100; + } + } + result +} + +/// Gravestone Doji (bearish) +pub fn cdlgravestonedoji(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body / range <= 0.1 && ls / range <= 0.1 && us >= range * 0.6 { + result[i] = -100; + } + } + result +} + +/// Hammer (bullish) +pub fn cdlhammer(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + let lower = lower_shadow(open[i], low[i], close[i]); + let upper = upper_shadow(open[i], high[i], close[i]); + if range > 0.0 && body > 0.0 && body <= range / 3.0 && lower >= 2.0 * body && upper <= body + { + result[i] = 100; + } + } + result +} + +/// Hanging Man (bearish) +pub fn cdlhangingman(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if range > 0.0 && body > 0.0 && ls >= body * 2.0 && us <= body && body / range <= 0.4 { + result[i] = -100; + } + } + result +} + +/// Harami +pub fn cdlharami(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let range1 = candle_range(h1, l1); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; + + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + let body2_high = o2.max(c2); + let body2_low = o2.min(c2); + + let inside = body2_high <= body1_high && body2_low >= body1_low && body2 < body1 * 0.6; + + if is_bearish(o1, c1) && large_body1 && inside && is_bullish(o2, c2) { + result[i] = 100; + } else if is_bullish(o1, c1) && large_body1 && inside && is_bearish(o2, c2) { + result[i] = -100; + } + } + result +} + +/// Harami Cross +pub fn cdlharamicross(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; + let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + let doji_mid = (o2 + c2) / 2.0; + let inside = doji_mid <= body1_high && doji_mid >= body1_low; + + if is_bearish(o1, c1) && large_body1 && is_doji2 && inside { + result[i] = 100; + } else if is_bullish(o1, c1) && large_body1 && is_doji2 && inside { + result[i] = -100; + } + } + result +} + +/// High-Wave Candle +pub fn cdlhighwave(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body / range <= 0.3 && us >= range * 0.3 && ls >= range * 0.3 { + if is_bullish(o, c) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Hikkake Pattern +pub fn cdlhikkake(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let h1 = high[i - 1]; + let l1 = low[i - 1]; + let h2 = high[i]; + let l2 = low[i]; + let inside = h1 <= h0 && l1 >= l0; + if !inside { + continue; + } + if is_bearish(o0, c0) && h2 > h1 && l2 > l1 { + result[i] = 100; + } else if is_bullish(o0, c0) && l2 < l1 && h2 < h1 { + result[i] = -100; + } + } + result +} + +/// Modified Hikkake Pattern +pub fn cdlhikkakemod(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 3..n { + let (o0, h0, l0, c0) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let h1 = high[i - 2]; + let l1 = low[i - 2]; + let h2 = high[i - 1]; + let l2 = low[i - 1]; + let h3 = high[i]; + let l3 = low[i]; + let inside = h1 <= h0 && l1 >= l0; + if !inside { + continue; + } + if is_bearish(o0, c0) && l2 < l1 && h3 > h1 && l3 > l1 { + result[i] = 100; + } else if is_bullish(o0, c0) && h2 > h1 && l3 < l1 && h3 < h1 { + result[i] = -100; + } + } + result +} + +/// Homing Pigeon (bullish) +pub fn cdlhomingpigeon(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, _h0, _l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let body0_high = o0.max(c0); + let body0_low = o0.min(c0); + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && body1_high <= body0_high + && body1_low >= body0_low + { + result[i] = 100; + } + } + result +} + +/// Identical Three Crows (bearish) +pub fn cdlidentical3crows(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let tol0 = range0 * 0.03; + let tol1 = range1 * 0.03; + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && is_bearish(o2, c2) + && c1 < c0 + && c2 < c1 + && (o1 - c0).abs() <= tol0 + && (o2 - c1).abs() <= tol1 + && range0 > 0.0 + && range1 > 0.0 + && candle_range(h2, l2) > 0.0 + { + result[i] = -100; + } + } + result +} + +/// In-Neck Pattern (bearish) +pub fn cdlinneck(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bullish(o1, c1) + && o1 < l0 + && (c1 - c0).abs() <= range0 * 0.03 + { + result[i] = -100; + } + } + result +} + +/// Inverted Hammer (bullish) +pub fn cdlinvertedhammer(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body > 0.0 && us >= body * 2.0 && ls <= body && body / range <= 0.4 { + result[i] = 100; + } + } + result +} + +/// Kicking +pub fn cdlkicking(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, h1, l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let maru0 = range0 > 0.0 + && upper_shadow(o0, h0, c0) <= range0 * 0.02 + && lower_shadow(o0, l0, c0) <= range0 * 0.02; + let maru1 = range1 > 0.0 + && upper_shadow(o1, h1, c1) <= range1 * 0.02 + && lower_shadow(o1, l1, c1) <= range1 * 0.02; + if is_bearish(o0, c0) && maru0 && is_bullish(o1, c1) && maru1 && o1 > o0 { + result[i] = 100; + } else if is_bullish(o0, c0) && maru0 && is_bearish(o1, c1) && maru1 && o1 < o0 { + result[i] = -100; + } + } + result +} + +/// Kicking — bull/bear determined by longer of the two marubozu +pub fn cdlkickingbylength(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, h1, l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let maru0 = range0 > 0.0 + && upper_shadow(o0, h0, c0) <= range0 * 0.02 + && lower_shadow(o0, l0, c0) <= range0 * 0.02; + let maru1 = range1 > 0.0 + && upper_shadow(o1, h1, c1) <= range1 * 0.02 + && lower_shadow(o1, l1, c1) <= range1 * 0.02; + let opposite = (is_bearish(o0, c0) && is_bullish(o1, c1)) + || (is_bullish(o0, c0) && is_bearish(o1, c1)); + let has_gap = (o1 - c0).abs() > 0.0; + if maru0 && maru1 && opposite && has_gap { + if range1 >= range0 { + if is_bullish(o1, c1) { + result[i] = 100; + } else { + result[i] = -100; + } + } else if is_bullish(o0, c0) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Ladder Bottom (bullish) +pub fn cdlladderbottom(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 4..n { + let (o0, _h0, _l0, c0) = (open[i - 4], high[i - 4], low[i - 4], close[i - 4]); + let (o1, _h1, _l1, c1) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let (o2, _h2, _l2, c2) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o3, h3, _l3, c3) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o4, h4, l4, c4) = (open[i], high[i], low[i], close[i]); + let three_bear = is_bearish(o0, c0) && is_bearish(o1, c1) && is_bearish(o2, c2); + let descend = c1 < c0 && c2 < c1; + let us3 = upper_shadow(o3, h3, c3); + let body3 = body_size(o3, c3); + let inv_hammer = us3 >= body3 * 1.5; + let range4 = candle_range(h4, l4); + let body4 = body_size(o4, c4); + let large_bull = is_bullish(o4, c4) && range4 > 0.0 && body4 >= range4 * 0.5; + if three_bear && descend && inv_hammer && large_bull && c4 > c2 { + result[i] = 100; + } + } + result +} + +/// Long Legged Doji +pub fn cdllongleggeddoji(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body / range <= 0.1 && us >= range * 0.3 && ls >= range * 0.3 { + result[i] = 100; + } + } + result +} + +/// Long Line Candle +pub fn cdllongline(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + if body >= range * 0.7 { + if is_bullish(o, c) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Marubozu +pub fn cdlmarubozu(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + let lower = lower_shadow(open[i], low[i], close[i]); + let upper = upper_shadow(open[i], high[i], close[i]); + if range > 0.0 && body >= range * 0.95 && upper <= range * 0.025 && lower <= range * 0.025 { + if is_bullish(open[i], close[i]) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Matching Low (bullish) +pub fn cdlmatchinglow(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let tol = range0 * 0.02; + if is_bearish(o0, c0) && is_bearish(o1, c1) && (c1 - c0).abs() <= tol { + result[i] = 100; + } + } + result +} + +/// Mat Hold (bullish) +pub fn cdlmathold(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 4..n { + let (o0, h0, l0, c0) = (open[i - 4], high[i - 4], low[i - 4], close[i - 4]); + let (o1, _h1, l1, c1) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let (o2, _h2, l2, c2) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o3, _h3, l3, c3) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o4, h4, l4, c4) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + let range4 = candle_range(h4, l4); + let body4 = body_size(o4, c4); + let large_bull0 = is_bullish(o0, c0) && range0 > 0.0 && body0 >= range0 * 0.5; + let small_bears = is_bearish(o1, c1) && is_bearish(o2, c2) && is_bearish(o3, c3); + let stay_above = l1 >= o0 && l2 >= o0 && l3 >= o0; + let large_bull4 = is_bullish(o4, c4) && range4 > 0.0 && body4 >= range4 * 0.5 && c4 > c0; + if large_bull0 && small_bears && stay_above && large_bull4 { + result[i] = 100; + } + } + result +} + +/// Morning Doji Star (bullish) +pub fn cdlmorningdojistar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range2 = candle_range(o2.min(c2) - DOJI_BODY_EPSILON, o2.max(c2)); + let range3 = candle_range(h3, l3); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; + + if is_bearish(o1, c1) + && large_body1 + && is_doji2 + && is_bullish(o3, c3) + && large_body3 + && c3 > (o1 + c1) / 2.0 + { + result[i] = 100; + } + } + result +} + +/// Morning Star (bullish) +pub fn cdlmorningstar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range3 = candle_range(h3, l3); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let small_body2 = range1 > 0.0 && body2 < body1 * 0.3; + let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; + + if is_bearish(o1, c1) + && large_body1 + && small_body2 + && is_bullish(o3, c3) + && large_body3 + && c3 > (o1 + c1) / 2.0 + { + result[i] = 100; + } + } + result +} + +/// On-Neck Pattern (bearish) +pub fn cdlonneck(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bullish(o1, c1) + && o1 < l0 + && (c1 - l0).abs() <= range0 * 0.03 + { + result[i] = -100; + } + } + result +} + +/// Piercing Pattern (bullish) +pub fn cdlpiercing(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let body0 = body_size(o0, c0); + let range0 = candle_range(h0, l0); + let midpoint0 = (o0 + c0) / 2.0; + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bullish(o1, c1) + && o1 < l0 + && c1 > midpoint0 + && c1 < o0 + { + result[i] = 100; + } + } + result +} + +/// Rickshaw Man +pub fn cdlrickshawman(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + let body_mid = (o + c) / 2.0; + let range_mid = (h + l) / 2.0; + let is_doji = body / range <= 0.1; + let long_shadows = us >= range * 0.3 && ls >= range * 0.3; + let near_center = (body_mid - range_mid).abs() <= range * 0.15; + if is_doji && long_shadows && near_center { + result[i] = 100; + } + } + result +} + +/// Rising/Falling Three Methods +pub fn cdlrisefall3methods(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 4..n { + let (o0, h0, l0, c0) = (open[i - 4], high[i - 4], low[i - 4], close[i - 4]); + let (o1, h1, l1, c1) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let (o2, h2, l2, c2) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o3, h3, l3, c3) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o4, h4, l4, c4) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + let range4 = candle_range(h4, l4); + let body4 = body_size(o4, c4); + if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_bearish(o1, c1) + && is_bearish(o2, c2) + && is_bearish(o3, c3) + && h1 <= h0 + && l1 >= l0 + && h2 <= h0 + && l2 >= l0 + && h3 <= h0 + && l3 >= l0 + && is_bullish(o4, c4) + && range4 > 0.0 + && body4 >= range4 * 0.5 + && c4 > c0 + && o4 > c3 + { + result[i] = 100; + } else if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_bullish(o1, c1) + && is_bullish(o2, c2) + && is_bullish(o3, c3) + && h1 <= h0 + && l1 >= l0 + && h2 <= h0 + && l2 >= l0 + && h3 <= h0 + && l3 >= l0 + && is_bearish(o4, c4) + && range4 > 0.0 + && body4 >= range4 * 0.5 + && c4 < c0 + && o4 < c3 + { + result[i] = -100; + } + } + result +} + +/// Separating Lines +pub fn cdlseparatinglines(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, h1, l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body1 = body_size(o1, c1); + let range1 = candle_range(h1, l1); + let same_open = range0 > 0.0 && (o1 - o0).abs() <= range0 * 0.02; + let long1 = range1 > 0.0 && body1 >= range1 * 0.5; + if is_bearish(o0, c0) && is_bullish(o1, c1) && same_open && long1 { + result[i] = 100; + } else if is_bullish(o0, c0) && is_bearish(o1, c1) && same_open && long1 { + result[i] = -100; + } + } + result +} + +/// Shooting Star (bearish) +pub fn cdlshootingstar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + let lower = lower_shadow(open[i], low[i], close[i]); + let upper = upper_shadow(open[i], high[i], close[i]); + if range > 0.0 && body > 0.0 && body <= range / 3.0 && upper >= 2.0 * body && lower <= body + { + result[i] = -100; + } + } + result +} + +/// Short Line Candle +pub fn cdlshortline(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + if body > 0.0 && body <= range * 0.3 { + if is_bullish(o, c) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Spinning Top +pub fn cdlspinningtop(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + let lower = lower_shadow(open[i], low[i], close[i]); + let upper = upper_shadow(open[i], high[i], close[i]); + if range > 0.0 && body > 0.0 && body <= range / 3.0 && upper > body && lower > body { + if is_bullish(open[i], close[i]) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Stalled Pattern (bearish) +pub fn cdlstalledpattern(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + if is_bullish(o0, c0) + && is_bullish(o1, c1) + && is_bullish(o2, c2) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && c1 > c0 + && c2 > c1 + && o1 >= o0 + && o1 <= c0 + && o2 >= c1 * 0.99 + && body2 < body1 * 0.7 + { + result[i] = -100; + } + } + result +} + +/// Stick Sandwich (bullish) +pub fn cdlsticksandwich(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let tol = range0 * 0.02; + if is_bearish(o0, c0) + && is_bullish(o1, c1) + && is_bearish(o2, c2) + && (c2 - c0).abs() <= tol + && o1 >= c0 + && c1 <= o0 + { + result[i] = 100; + } + } + result +} + +/// Takuri (Dragonfly Doji with very long lower shadow) +pub fn cdltakuri(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c) + DOJI_BODY_EPSILON; + let ls = lower_shadow(o, l, c); + let us = upper_shadow(o, h, c); + if ls >= body * 3.0 && us <= range * 0.1 { + result[i] = 100; + } + } + result +} + +/// Tasuki Gap +pub fn cdltasukigap(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, _h0, _l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + if is_bullish(o0, c0) + && is_bullish(o1, c1) + && o1 > c0 + && is_bearish(o2, c2) + && o2 >= l1 + && o2 <= c1 + && c2 > c0 + && c2 < o1 + { + result[i] = 100; + } else if is_bearish(o0, c0) + && is_bearish(o1, c1) + && o1 < c0 + && is_bullish(o2, c2) + && o2 >= c1 + && o2 <= h1 + && c2 < c0 + && c2 > o1 + { + result[i] = -100; + } + } + result +} + +/// Thrusting Pattern (bearish) +pub fn cdlthrusting(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let body0 = body_size(o0, c0); + let range0 = candle_range(h0, l0); + let midpoint0 = (o0 + c0) / 2.0; + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bullish(o1, c1) + && o1 < l0 + && c1 > c0 + && c1 < midpoint0 + { + result[i] = -100; + } + } + result +} + +/// Tristar Pattern +pub fn cdltristar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let doji0 = range0 > 0.0 && body0 / range0 <= 0.1; + let doji1 = range1 > 0.0 && body1 / range1 <= 0.1; + let doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + if doji0 && doji1 && doji2 { + if l1 < l0 && h1 < h0 && c2 > c1 { + result[i] = 100; + } else if l1 > l0 && h1 > h0 && c2 < c1 { + result[i] = -100; + } + } + } + result +} + +/// Unique 3 River (bullish) +pub fn cdlunique3river(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + let body2 = body_size(o2, c2); + let range2 = candle_range(h2, l2); + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bearish(o1, c1) + && l1 < l0 + && lower_shadow(o1, l1, c1) > 0.0 + && is_bullish(o2, c2) + && range2 > 0.0 + && body2 <= range2 * 0.5 + && c2 < c1 + && c2 > l1 + { + result[i] = 100; + } + } + result +} + +/// Upside Gap Two Crows (bearish) +pub fn cdlupsidegap2crows(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bearish(o1, c1) + && o1 > c0 + && is_bearish(o2, c2) + && o2 > o1 + && c2 < o1 + && c2 > c0 + { + result[i] = -100; + } + } + result +} + +/// Upside/Downside Gap Three Methods +pub fn cdlxsidegap3methods(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, _h0, _l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + if is_bullish(o0, c0) + && is_bullish(o1, c1) + && o1 > c0 + && is_bearish(o2, c2) + && o2 <= c1 + && o2 >= o1 + && c2 >= c0 + && c2 <= o1 + { + result[i] = 100; + } else if is_bearish(o0, c0) + && is_bearish(o1, c1) + && o1 < c0 + && is_bullish(o2, c2) + && o2 >= c1 + && o2 <= o1 + && c2 <= c0 + && c2 >= o1 + { + result[i] = -100; + } + } + result +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_doji_basic() { + let open = vec![10.0]; + let high = vec![11.0]; + let low = vec![9.0]; + let close = vec![10.0]; + let r = cdldoji(&open, &high, &low, &close); + assert_eq!(r, vec![100]); + } + + #[test] + fn test_doji_not_detected() { + let open = vec![9.0]; + let high = vec![11.0]; + let low = vec![9.0]; + let close = vec![11.0]; + let r = cdldoji(&open, &high, &low, &close); + assert_eq!(r, vec![0]); + } + + #[test] + fn test_engulfing_bullish() { + // prev: bearish (open=10, close=8), body_high=10, body_low=8 + // curr: bullish (open=7.5, close=11), body_high=11, body_low=7.5 + // curr engulfs prev: 11>10 && 7.5<8 + let open = vec![10.0, 7.5]; + let high = vec![10.5, 11.5]; + let low = vec![7.5, 7.0]; + let close = vec![8.0, 11.0]; + let r = cdlengulfing(&open, &high, &low, &close); + assert_eq!(r[1], 100); + } + + #[test] + fn test_engulfing_bearish() { + let open = vec![8.0, 11.5]; + let high = vec![11.0, 12.0]; + let low = vec![7.5, 7.0]; + let close = vec![11.0, 7.5]; + let r = cdlengulfing(&open, &high, &low, &close); + assert_eq!(r[1], -100); + } + + #[test] + fn test_hammer() { + let open = vec![10.0]; + let high = vec![10.2]; + let low = vec![7.0]; + let close = vec![10.1]; + let r = cdlhammer(&open, &high, &low, &close); + assert_eq!(r[0], 100); + } + + #[test] + fn test_marubozu_bullish() { + let open = vec![10.0]; + let high = vec![12.0]; + let low = vec![10.0]; + let close = vec![12.0]; + let r = cdlmarubozu(&open, &high, &low, &close); + assert_eq!(r[0], 100); + } + + #[test] + fn test_empty_input() { + let empty: Vec = vec![]; + let r = cdldoji(&empty, &empty, &empty, &empty); + assert!(r.is_empty()); + } + + #[test] + fn test_morning_star() { + let open = vec![20.0, 14.5, 15.0]; + let high = vec![20.5, 15.0, 19.5]; + let low = vec![14.0, 14.0, 14.5]; + let close = vec![14.5, 14.6, 19.0]; + let r = cdlmorningstar(&open, &high, &low, &close); + assert_eq!(r[2], 100); + } + + #[test] + fn test_validate_ohlc_mismatch() { + let a = vec![1.0, 2.0]; + let b = vec![1.0]; + assert!(validate_ohlc(&a, &b, &a, &a).is_err()); + } +} diff --git a/crates/ferro_ta_core/src/portfolio.rs b/crates/ferro_ta_core/src/portfolio.rs new file mode 100644 index 0000000..513be3c --- /dev/null +++ b/crates/ferro_ta_core/src/portfolio.rs @@ -0,0 +1,627 @@ +//! 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], 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::() / n as f64; + let mean_b: f64 = benchmark_returns.iter().sum::() / 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 { + 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::() / window as f64; + let mean_b: f64 = b_win.iter().sum::() / 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) { + 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>`. +pub fn correlation_matrix(data: &[Vec]) -> Vec> { + 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)"); + 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::() / 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::() / 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]; + 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 { + 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 { + 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 { + 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 { + 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::() / window as f64; + let var: f64 = win.iter().map(|v| (v - mean).powi(2)).sum::() / 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], weights: &[f64]) -> Vec { + 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(); + 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 = 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 = 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 = 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![]; + let weights: Vec = vec![]; + let cw = compose_weighted(&data, &weights); + assert!(cw.is_empty()); + } +} diff --git a/crates/ferro_ta_core/src/price_transform.rs b/crates/ferro_ta_core/src/price_transform.rs new file mode 100644 index 0000000..a42f746 --- /dev/null +++ b/crates/ferro_ta_core/src/price_transform.rs @@ -0,0 +1,89 @@ +//! 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 { + 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 { + 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 { + 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 { + 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 = 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()); + } +} diff --git a/crates/ferro_ta_core/src/regime.rs b/crates/ferro_ta_core/src/regime.rs new file mode 100644 index 0000000..285689a --- /dev/null +++ b/crates/ferro_ta_core/src/regime.rs @@ -0,0 +1,171 @@ +//! 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`: `1` = trend (ADX > threshold), `0` = range, `-1` = NaN/warmup. +pub fn regime_adx(adx: &[f64], threshold: f64) -> Vec { + 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`: `1` = trend, `0` = range, `-1` = NaN. +pub fn regime_combined( + adx: &[f64], + atr: &[f64], + close: &[f64], + adx_threshold: f64, + atr_pct_threshold: f64, +) -> Vec { + 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`: `1` at break bars, `0` elsewhere. +pub fn detect_breaks_cusum( + series: &[f64], + window: usize, + threshold: f64, + slack: f64, +) -> Vec { + 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::() / window as f64; + let var: f64 = + slice.iter().map(|&v| (v - mean) * (v - mean)).sum::() / (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`: `1` at break bars, `0` elsewhere. +pub fn rolling_variance_break( + series: &[f64], + short_window: usize, + long_window: usize, + threshold: f64, +) -> Vec { + 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::() / k as f64; + slice.iter().map(|&v| (v - mean) * (v - mean)).sum::() / (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()); + } +} diff --git a/crates/ferro_ta_core/src/resampling.rs b/crates/ferro_ta_core/src/resampling.rs new file mode 100644 index 0000000..5a81f31 --- /dev/null +++ b/crates/ferro_ta_core/src/resampling.rs @@ -0,0 +1,278 @@ +//! 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, Vec, Vec, Vec, Vec); + +// --------------------------------------------------------------------------- +// 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 = Vec::new(); + let mut out_high: Vec = Vec::new(); + let mut out_low: Vec = Vec::new(); + let mut out_close: Vec = Vec::new(); + let mut out_vol: Vec = 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 = Vec::new(); + let mut out_high: Vec = Vec::new(); + let mut out_low: Vec = Vec::new(); + let mut out_close: Vec = Vec::new(); + let mut out_vol: Vec = 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(&[], &[], &[], &[], &[], &[]); + } +} diff --git a/crates/ferro_ta_core/src/signals.rs b/crates/ferro_ta_core/src/signals.rs new file mode 100644 index 0000000..1c83e86 --- /dev/null +++ b/crates/ferro_ta_core/src/signals.rs @@ -0,0 +1,131 @@ +//! 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 { + let n = x.len(); + let mut order: Vec = (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 { + 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 { + let len = x.len(); + let k = n.min(len); + let mut order: Vec = (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 { + let len = x.len(); + let k = n.min(len); + let mut order: Vec = (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); + } +} diff --git a/crates/ferro_ta_core/src/statistic.rs b/crates/ferro_ta_core/src/statistic.rs index 5c25136..d87ca26 100644 --- a/crates/ferro_ta_core/src/statistic.rs +++ b/crates/ferro_ta_core/src/statistic.rs @@ -1,6 +1,14 @@ //! Statistic functions. -/// Standard deviation — population (`ddof = 0`). +/// 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). pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec { let n = real.len(); let mut result = vec![f64::NAN; n]; diff --git a/crates/ferro_ta_core/src/streaming.rs b/crates/ferro_ta_core/src/streaming.rs new file mode 100644 index 0000000..98f4f67 --- /dev/null +++ b/crates/ferro_ta_core/src/streaming.rs @@ -0,0 +1,947 @@ +//! 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, + 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::() / 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, + 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::() / 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, + running_sum: f64, + count: usize, +} + +impl StreamingSMA { + pub fn new(period: usize) -> Result { + 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 { + 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, + losses: Vec, + avg_gain: f64, + avg_loss: f64, + seeded: bool, +} + +impl StreamingRSI { + pub fn new(period: usize) -> Result { + 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::() / self.period as f64; + self.avg_loss = self.losses.iter().sum::() / 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 { + 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, + mean: f64, + m2: f64, +} + +impl StreamingBBands { + pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> Result { + 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 { + 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, + low_buf: VecDeque, + fastk_buf: VecDeque, + slowk_buf: VecDeque, +} + +impl StreamingStoch { + pub fn new( + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, + ) -> Result { + 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::() / 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::() / 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 { + 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 = 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::() / period as f64; + assert!( + approx_eq(streaming[i], batch, 1e-10), + "bar {}: streaming={} batch={}", + i, + streaming[i], + batch + ); + } + } + } +} diff --git a/crates/ferro_ta_core/src/volatility.rs b/crates/ferro_ta_core/src/volatility.rs index 142944a..277f69b 100644 --- a/crates/ferro_ta_core/src/volatility.rs +++ b/crates/ferro_ta_core/src/volatility.rs @@ -1,10 +1,15 @@ //! Volatility indicators. -/// Average True Range — Wilder smoothed (TA-Lib compatible). +/// Compute the Average True Range (ATR), Wilder smoothed (TA-Lib compatible). /// -/// Seeds ATR with SMA of TR[1..=timeperiod] (bar 0 is skipped, matching TA-Lib). -/// First valid output is at index `timeperiod`; indices 0..timeperiod are NaN. -/// TR is computed on-the-fly (no separate tr Vec allocation). +/// 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). pub fn atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { let n = high.len(); let mut result = vec![f64::NAN; n]; @@ -33,7 +38,14 @@ pub fn atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec Vec { let n = high.len(); let mut result = vec![f64::NAN; n]; diff --git a/crates/ferro_ta_core/src/volume.rs b/crates/ferro_ta_core/src/volume.rs index 29ad580..db6d89f 100644 --- a/crates/ferro_ta_core/src/volume.rs +++ b/crates/ferro_ta_core/src/volume.rs @@ -1,6 +1,14 @@ //! Volume indicators. -/// On-Balance Volume. +/// 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` of length `n` with no `NaN` values. +/// +/// # Arguments +/// * `close` - Price series. +/// * `volume` - Volume series (same length as `close`). pub fn obv(close: &[f64], volume: &[f64]) -> Vec { let n = close.len(); let mut result = vec![0.0_f64; n]; @@ -21,11 +29,17 @@ pub fn obv(close: &[f64], volume: &[f64]) -> Vec { result } -/// Money Flow Index — O(n) sliding-window implementation without per-bar allocation. +/// Compute the Money Flow Index (MFI). /// -/// 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. +/// 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). pub fn mfi( high: &[f64], low: &[f64], @@ -78,6 +92,51 @@ 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 { + 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 { + 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::*; @@ -92,6 +151,33 @@ mod tests { assert!((result[2] - 600.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 = (1..=n).map(|i| i as f64 + 1.0).collect(); + let l: Vec = (1..=n).map(|i| i as f64 - 1.0).collect(); + let c: Vec = (1..=n).map(|i| i as f64).collect(); + let v: Vec = 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()); + } + } + #[test] fn mfi_range() { let n = 50; diff --git a/docs/adjacent_tooling.rst b/docs/adjacent_tooling.rst index c7d6688..3b0a6e1 100644 --- a/docs/adjacent_tooling.rst +++ b/docs/adjacent_tooling.rst @@ -10,6 +10,11 @@ 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, @@ -36,6 +41,74 @@ 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 ----------------------- diff --git a/docs/api_manifest.json b/docs/api_manifest.json index 86c4b3a..8cf0564 100644 --- a/docs/api_manifest.json +++ b/docs/api_manifest.json @@ -1,4986 +1,55 @@ { - "surfaces": { - "python": { - "indicator_count": 208, - "method_count": 447, - "categories": [ - "aggregation", - "alerts", - "batch", - "crypto", - "cycle", - "extended", - "features", - "math_ops", - "momentum", - "overlap", - "pattern", - "portfolio", - "price_transform", - "regime", - "resampling", - "signals", - "statistic", - "streaming", - "volatility", - "volume" - ], - "indicators": [ - { - "name": "ACOS", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "AD", - "category": "volume", - "module": "ferro_ta.indicators.volume", - "doc": "", - "params": [] - }, - { - "name": "ADD", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "ADOSC", - "category": "volume", - "module": "ferro_ta.indicators.volume", - "doc": "", - "params": [] - }, - { - "name": "ADX", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ADXR", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "APO", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "AROON", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "AROONOSC", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ASIN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "ATAN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "ATR", - "category": "volatility", - "module": "ferro_ta.indicators.volatility", - "doc": "", - "params": [] - }, - { - "name": "AVGPRICE", - "category": "price_transform", - "module": "ferro_ta.indicators.price_transform", - "doc": "", - "params": [] - }, - { - "name": "AlertEvent", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "AlertManager", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "BBANDS", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "BETA", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "BOP", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "CCI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "CDL2CROWS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3BLACKCROWS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3INSIDE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3LINESTRIKE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3OUTSIDE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3STARSINSOUTH", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3WHITESOLDIERS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLABANDONEDBABY", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLADVANCEBLOCK", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLBELTHOLD", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLBREAKAWAY", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLCLOSINGMARUBOZU", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLCONCEALBABYSWALL", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLCOUNTERATTACK", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLDARKCLOUDCOVER", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLDOJI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLDOJISTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLDRAGONFLYDOJI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLENGULFING", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLEVENINGDOJISTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLEVENINGSTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLGAPSIDESIDEWHITE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLGRAVESTONEDOJI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHAMMER", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHANGINGMAN", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHARAMI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHARAMICROSS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHIGHWAVE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHIKKAKE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHIKKAKEMOD", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHOMINGPIGEON", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLIDENTICAL3CROWS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLINNECK", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLINVERTEDHAMMER", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLKICKING", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLKICKINGBYLENGTH", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLLADDERBOTTOM", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLLONGLEGGEDDOJI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLLONGLINE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMARUBOZU", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMATCHINGLOW", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMATHOLD", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMORNINGDOJISTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMORNINGSTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLONNECK", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLPIERCING", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLRICKSHAWMAN", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLRISEFALL3METHODS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSEPARATINGLINES", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSHOOTINGSTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSHORTLINE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSPINNINGTOP", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSTALLEDPATTERN", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSTICKSANDWICH", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLTAKURI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLTASUKIGAP", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLTHRUSTING", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLTRISTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLUNIQUE3RIVER", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLUPSIDEGAP2CROWS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLXSIDEGAP3METHODS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CEIL", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "CHANDELIER_EXIT", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "CHOPPINESS_INDEX", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "CMO", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "CORREL", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "COS", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "COSH", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "DEMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "DIV", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "DONCHIAN", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "DX", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "EMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "EXP", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "FLOOR", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "HT_DCPERIOD", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_DCPHASE", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_PHASOR", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_SINE", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_TRENDLINE", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_TRENDMODE", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HULL_MA", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "ICHIMOKU", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "KAMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "KELTNER_CHANNELS", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_ANGLE", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_INTERCEPT", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_SLOPE", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "LN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "LOG10", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MACD", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MACDEXT", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MACDFIX", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MAMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MAVP", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MAX", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MAXINDEX", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MEDPRICE", - "category": "price_transform", - "module": "ferro_ta.indicators.price_transform", - "doc": "", - "params": [] - }, - { - "name": "MFI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "MIDPOINT", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MIDPRICE", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MIN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MININDEX", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MINUS_DI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "MINUS_DM", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "MOM", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "MULT", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "NATR", - "category": "volatility", - "module": "ferro_ta.indicators.volatility", - "doc": "", - "params": [] - }, - { - "name": "OBV", - "category": "volume", - "module": "ferro_ta.indicators.volume", - "doc": "", - "params": [] - }, - { - "name": "PIVOT_POINTS", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "PLUS_DI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "PLUS_DM", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "PPO", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ROC", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ROCP", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ROCR", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ROCR100", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "RSI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "SAR", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "SAREXT", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "SIN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SINH", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "SQRT", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "STDDEV", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "STOCH", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "STOCHF", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "STOCHRSI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "SUB", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SUM", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SUPERTREND", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "StreamingATR", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingBBands", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingEMA", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingMACD", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingRSI", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingSMA", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingStoch", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingSupertrend", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingVWAP", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "T3", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "TAN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "TANH", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "TEMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "TRANGE", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "TRIMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "TRIX", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "TSF", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "TYPPRICE", - "category": "price_transform", - "module": "ferro_ta.indicators.price_transform", - "doc": "", - "params": [] - }, - { - "name": "TickAggregator", - "category": "aggregation", - "module": "ferro_ta.data.aggregation", - "doc": "", - "params": [] - }, - { - "name": "ULTOSC", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "VAR", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "VWAP", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "VWMA", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "WCLPRICE", - "category": "price_transform", - "module": "ferro_ta.indicators.price_transform", - "doc": "", - "params": [] - }, - { - "name": "WILLR", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "WMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "aggregate_ticks", - "category": "aggregation", - "module": "ferro_ta.data.aggregation", - "doc": "", - "params": [] - }, - { - "name": "batch_apply", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "batch_ema", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "batch_rsi", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "batch_sma", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "beta", - "category": "portfolio", - "module": "ferro_ta.analysis.portfolio", - "doc": "", - "params": [] - }, - { - "name": "check_cross", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "check_threshold", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "collect_alert_bars", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "compose", - "category": "signals", - "module": "ferro_ta.analysis.signals", - "doc": "", - "params": [] - }, - { - "name": "compute_many", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "continuous_bar_labels", - "category": "crypto", - "module": "ferro_ta.analysis.crypto", - "doc": "", - "params": [] - }, - { - "name": "correlation_matrix", - "category": "portfolio", - "module": "ferro_ta.analysis.portfolio", - "doc": "", - "params": [] - }, - { - "name": "detect_breaks_cusum", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "drawdown", - "category": "portfolio", - "module": "ferro_ta.analysis.portfolio", - "doc": "", - "params": [] - }, - { - "name": "feature_matrix", - "category": "features", - "module": "ferro_ta.analysis.features", - "doc": "", - "params": [] - }, - { - "name": "funding_pnl", - "category": "crypto", - "module": "ferro_ta.analysis.crypto", - "doc": "", - "params": [] - }, - { - "name": "multi_timeframe", - "category": "resampling", - "module": "ferro_ta.data.resampling", - "doc": "", - "params": [] - }, - { - "name": "portfolio_volatility", - "category": "portfolio", - "module": "ferro_ta.analysis.portfolio", - "doc": "", - "params": [] - }, - { - "name": "rank_signals", - "category": "signals", - "module": "ferro_ta.analysis.signals", - "doc": "", - "params": [] - }, - { - "name": "regime", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "regime_adx", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "regime_combined", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "resample", - "category": "resampling", - "module": "ferro_ta.data.resampling", - "doc": "", - "params": [] - }, - { - "name": "resample_continuous", - "category": "crypto", - "module": "ferro_ta.analysis.crypto", - "doc": "", - "params": [] - }, - { - "name": "rolling_variance_break", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "screen", - "category": "signals", - "module": "ferro_ta.analysis.signals", - "doc": "", - "params": [] - }, - { - "name": "session_boundaries", - "category": "crypto", - "module": "ferro_ta.analysis.crypto", - "doc": "", - "params": [] - }, - { - "name": "structural_breaks", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "volume_bars", - "category": "resampling", - "module": "ferro_ta.data.resampling", - "doc": "", - "params": [] - } - ], - "methods": [ - { - "name": "TickAggregator", - "category": "aggregation", - "module": "ferro_ta.data.aggregation", - "doc": "", - "params": [] - }, - { - "name": "aggregate_ticks", - "category": "aggregation", - "module": "ferro_ta.data.aggregation", - "doc": "", - "params": [] - }, - { - "name": "AlertEvent", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "AlertManager", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "check_cross", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "check_threshold", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "collect_alert_bars", - "category": "alerts", - "module": "ferro_ta.tools.alerts", - "doc": "", - "params": [] - }, - { - "name": "TradeStats", - "category": "attribution", - "module": "ferro_ta.analysis.attribution", - "doc": "", - "params": [] - }, - { - "name": "attribution_by_month", - "category": "attribution", - "module": "ferro_ta.analysis.attribution", - "doc": "", - "params": [] - }, - { - "name": "attribution_by_signal", - "category": "attribution", - "module": "ferro_ta.analysis.attribution", - "doc": "", - "params": [] - }, - { - "name": "from_backtest", - "category": "attribution", - "module": "ferro_ta.analysis.attribution", - "doc": "", - "params": [] - }, - { - "name": "trade_stats", - "category": "attribution", - "module": "ferro_ta.analysis.attribution", - "doc": "", - "params": [] - }, - { - "name": "batch_apply", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "batch_ema", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "batch_rsi", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "batch_sma", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "compute_many", - "category": "batch", - "module": "ferro_ta.data.batch", - "doc": "", - "params": [] - }, - { - "name": "ratio", - "category": "cross_asset", - "module": "ferro_ta.analysis.cross_asset", - "doc": "", - "params": [] - }, - { - "name": "relative_strength", - "category": "cross_asset", - "module": "ferro_ta.analysis.cross_asset", - "doc": "", - "params": [] - }, - { - "name": "rolling_beta", - "category": "cross_asset", - "module": "ferro_ta.analysis.cross_asset", - "doc": "", - "params": [] - }, - { - "name": "spread", - "category": "cross_asset", - "module": "ferro_ta.analysis.cross_asset", - "doc": "", - "params": [] - }, - { - "name": "zscore", - "category": "cross_asset", - "module": "ferro_ta.analysis.cross_asset", - "doc": "", - "params": [] - }, - { - "name": "continuous_bar_labels", - "category": "crypto", - "module": "ferro_ta.analysis.crypto", - "doc": "", - "params": [] - }, - { - "name": "funding_pnl", - "category": "crypto", - "module": "ferro_ta.analysis.crypto", - "doc": "", - "params": [] - }, - { - "name": "resample_continuous", - "category": "crypto", - "module": "ferro_ta.analysis.crypto", - "doc": "", - "params": [] - }, - { - "name": "session_boundaries", - "category": "crypto", - "module": "ferro_ta.analysis.crypto", - "doc": "", - "params": [] - }, - { - "name": "HT_DCPERIOD", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_DCPHASE", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_PHASOR", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_SINE", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_TRENDLINE", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "HT_TRENDMODE", - "category": "cycle", - "module": "ferro_ta.indicators.cycle", - "doc": "", - "params": [] - }, - { - "name": "PayoffLeg", - "category": "derivatives_payoff", - "module": "ferro_ta.analysis.derivatives_payoff", - "doc": "", - "params": [] - }, - { - "name": "aggregate_greeks", - "category": "derivatives_payoff", - "module": "ferro_ta.analysis.derivatives_payoff", - "doc": "", - "params": [] - }, - { - "name": "futures_leg_payoff", - "category": "derivatives_payoff", - "module": "ferro_ta.analysis.derivatives_payoff", - "doc": "", - "params": [] - }, - { - "name": "option_leg_payoff", - "category": "derivatives_payoff", - "module": "ferro_ta.analysis.derivatives_payoff", - "doc": "", - "params": [] - }, - { - "name": "strategy_payoff", - "category": "derivatives_payoff", - "module": "ferro_ta.analysis.derivatives_payoff", - "doc": "", - "params": [] - }, - { - "name": "CHANDELIER_EXIT", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "CHOPPINESS_INDEX", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "DONCHIAN", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "HULL_MA", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "ICHIMOKU", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "KELTNER_CHANNELS", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "PIVOT_POINTS", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "SUPERTREND", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "VWAP", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "VWMA", - "category": "extended", - "module": "ferro_ta.indicators.extended", - "doc": "", - "params": [] - }, - { - "name": "feature_matrix", - "category": "features", - "module": "ferro_ta.analysis.features", - "doc": "", - "params": [] - }, - { - "name": "CurveSummary", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "annualized_basis", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "back_adjusted_continuous_contract", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "basis", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "calendar_spreads", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "carry_spread", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "curve_slope", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "curve_summary", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "implied_carry_rate", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "parity_gap", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "ratio_adjusted_continuous_contract", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "roll_yield", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "synthetic_forward", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "synthetic_spot", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "weighted_continuous_contract", - "category": "futures", - "module": "ferro_ta.analysis.futures", - "doc": "", - "params": [] - }, - { - "name": "ACOS", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "ADD", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "ASIN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "ATAN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "CEIL", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "COS", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "COSH", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "DIV", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "EXP", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "FLOOR", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "LN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "LOG10", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MAX", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MAXINDEX", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MIN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MININDEX", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "MULT", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SIN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SINH", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SQRT", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SUB", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "SUM", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "TAN", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "TANH", - "category": "math_ops", - "module": "ferro_ta.indicators.math_ops", - "doc": "", - "params": [] - }, - { - "name": "ADX", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ADXR", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "APO", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "AROON", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "AROONOSC", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "BOP", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "CCI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "CMO", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "DX", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "MFI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "MINUS_DI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "MINUS_DM", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "MOM", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "PLUS_DI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "PLUS_DM", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "PPO", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ROC", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ROCP", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ROCR", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ROCR100", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "RSI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "STOCH", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "STOCHF", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "STOCHRSI", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "TRANGE", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "TRIX", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "ULTOSC", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "WILLR", - "category": "momentum", - "module": "ferro_ta.indicators.momentum", - "doc": "", - "params": [] - }, - { - "name": "OptionGreeks", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "SmileMetrics", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "black_76_price", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "black_scholes_price", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "greeks", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "implied_volatility", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "iv_percentile", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "iv_rank", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "iv_zscore", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "label_moneyness", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "option_price", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "select_strike", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "smile_metrics", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "term_structure_slope", - "category": "options", - "module": "ferro_ta.analysis.options", - "doc": "", - "params": [] - }, - { - "name": "DerivativesStrategy", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "ExpirySelector", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "ExpirySelectorKind", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "LegPreset", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "RiskControl", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "RiskMode", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "SimulationLimits", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "StrategyLeg", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "StrikeSelector", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "StrikeSelectorKind", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "build_strategy_preset", - "category": "options_strategy", - "module": "ferro_ta.analysis.options_strategy", - "doc": "", - "params": [] - }, - { - "name": "BBANDS", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "DEMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "EMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "KAMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MACD", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MACDEXT", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MACDFIX", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MAMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MAVP", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MIDPOINT", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "MIDPRICE", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "SAR", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "SAREXT", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "SMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "T3", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "TEMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "TRIMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "WMA", - "category": "overlap", - "module": "ferro_ta.indicators.overlap", - "doc": "", - "params": [] - }, - { - "name": "CDL2CROWS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3BLACKCROWS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3INSIDE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3LINESTRIKE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3OUTSIDE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3STARSINSOUTH", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDL3WHITESOLDIERS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLABANDONEDBABY", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLADVANCEBLOCK", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLBELTHOLD", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLBREAKAWAY", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLCLOSINGMARUBOZU", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLCONCEALBABYSWALL", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLCOUNTERATTACK", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLDARKCLOUDCOVER", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLDOJI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLDOJISTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLDRAGONFLYDOJI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLENGULFING", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLEVENINGDOJISTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLEVENINGSTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLGAPSIDESIDEWHITE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLGRAVESTONEDOJI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHAMMER", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHANGINGMAN", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHARAMI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHARAMICROSS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHIGHWAVE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHIKKAKE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHIKKAKEMOD", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLHOMINGPIGEON", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLIDENTICAL3CROWS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLINNECK", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLINVERTEDHAMMER", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLKICKING", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLKICKINGBYLENGTH", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLLADDERBOTTOM", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLLONGLEGGEDDOJI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLLONGLINE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMARUBOZU", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMATCHINGLOW", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMATHOLD", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMORNINGDOJISTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLMORNINGSTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLONNECK", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLPIERCING", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLRICKSHAWMAN", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLRISEFALL3METHODS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSEPARATINGLINES", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSHOOTINGSTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSHORTLINE", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSPINNINGTOP", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSTALLEDPATTERN", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLSTICKSANDWICH", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLTAKURI", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLTASUKIGAP", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLTHRUSTING", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLTRISTAR", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLUNIQUE3RIVER", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLUPSIDEGAP2CROWS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "CDLXSIDEGAP3METHODS", - "category": "pattern", - "module": "ferro_ta.indicators.pattern", - "doc": "", - "params": [] - }, - { - "name": "beta", - "category": "portfolio", - "module": "ferro_ta.analysis.portfolio", - "doc": "", - "params": [] - }, - { - "name": "correlation_matrix", - "category": "portfolio", - "module": "ferro_ta.analysis.portfolio", - "doc": "", - "params": [] - }, - { - "name": "drawdown", - "category": "portfolio", - "module": "ferro_ta.analysis.portfolio", - "doc": "", - "params": [] - }, - { - "name": "portfolio_volatility", - "category": "portfolio", - "module": "ferro_ta.analysis.portfolio", - "doc": "", - "params": [] - }, - { - "name": "AVGPRICE", - "category": "price_transform", - "module": "ferro_ta.indicators.price_transform", - "doc": "", - "params": [] - }, - { - "name": "MEDPRICE", - "category": "price_transform", - "module": "ferro_ta.indicators.price_transform", - "doc": "", - "params": [] - }, - { - "name": "TYPPRICE", - "category": "price_transform", - "module": "ferro_ta.indicators.price_transform", - "doc": "", - "params": [] - }, - { - "name": "WCLPRICE", - "category": "price_transform", - "module": "ferro_ta.indicators.price_transform", - "doc": "", - "params": [] - }, - { - "name": "detect_breaks_cusum", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "regime", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "regime_adx", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "regime_combined", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "rolling_variance_break", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "structural_breaks", - "category": "regime", - "module": "ferro_ta.analysis.regime", - "doc": "", - "params": [] - }, - { - "name": "multi_timeframe", - "category": "resampling", - "module": "ferro_ta.data.resampling", - "doc": "", - "params": [] - }, - { - "name": "resample", - "category": "resampling", - "module": "ferro_ta.data.resampling", - "doc": "", - "params": [] - }, - { - "name": "volume_bars", - "category": "resampling", - "module": "ferro_ta.data.resampling", - "doc": "", - "params": [] - }, - { - "name": "compose", - "category": "signals", - "module": "ferro_ta.analysis.signals", - "doc": "", - "params": [] - }, - { - "name": "rank_signals", - "category": "signals", - "module": "ferro_ta.analysis.signals", - "doc": "", - "params": [] - }, - { - "name": "screen", - "category": "signals", - "module": "ferro_ta.analysis.signals", - "doc": "", - "params": [] - }, - { - "name": "BETA", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "CORREL", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_ANGLE", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_INTERCEPT", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_SLOPE", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "STDDEV", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "TSF", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "VAR", - "category": "statistic", - "module": "ferro_ta.indicators.statistic", - "doc": "", - "params": [] - }, - { - "name": "StreamingATR", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingBBands", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingEMA", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingMACD", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingRSI", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingSMA", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingStoch", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingSupertrend", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "StreamingVWAP", - "category": "streaming", - "module": "ferro_ta.data.streaming", - "doc": "", - "params": [] - }, - { - "name": "compute_indicator", - "category": "tools", - "module": "ferro_ta.tools.tools", - "doc": "", - "params": [] - }, - { - "name": "describe_indicator", - "category": "tools", - "module": "ferro_ta.tools.tools", - "doc": "", - "params": [] - }, - { - "name": "list_indicators", - "category": "tools", - "module": "ferro_ta.tools.tools", - "doc": "", - "params": [] - }, - { - "name": "run_backtest", - "category": "tools", - "module": "ferro_ta.tools.tools", - "doc": "", - "params": [] - }, - { - "name": "ACOS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "AD", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ADD", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ADOSC", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ADX", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ADXR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "APO", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "AROON", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "AROONOSC", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ASIN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ATAN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ATR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "AVGPRICE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "BBANDS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "BETA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "BOP", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CCI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDL2CROWS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDL3BLACKCROWS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDL3INSIDE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDL3LINESTRIKE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDL3OUTSIDE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDL3STARSINSOUTH", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDL3WHITESOLDIERS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLABANDONEDBABY", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLADVANCEBLOCK", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLBELTHOLD", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLBREAKAWAY", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLCLOSINGMARUBOZU", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLCONCEALBABYSWALL", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLCOUNTERATTACK", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLDARKCLOUDCOVER", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLDOJI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLDOJISTAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLDRAGONFLYDOJI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLENGULFING", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLEVENINGDOJISTAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLEVENINGSTAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLGAPSIDESIDEWHITE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLGRAVESTONEDOJI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLHAMMER", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLHANGINGMAN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLHARAMI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLHARAMICROSS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLHIGHWAVE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLHIKKAKE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLHIKKAKEMOD", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLHOMINGPIGEON", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLIDENTICAL3CROWS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLINNECK", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLINVERTEDHAMMER", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLKICKING", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLKICKINGBYLENGTH", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLLADDERBOTTOM", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLLONGLEGGEDDOJI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLLONGLINE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLMARUBOZU", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLMATCHINGLOW", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLMATHOLD", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLMORNINGDOJISTAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLMORNINGSTAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLONNECK", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLPIERCING", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLRICKSHAWMAN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLRISEFALL3METHODS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLSEPARATINGLINES", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLSHOOTINGSTAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLSHORTLINE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLSPINNINGTOP", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLSTALLEDPATTERN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLSTICKSANDWICH", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLTAKURI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLTASUKIGAP", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLTHRUSTING", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLTRISTAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLUNIQUE3RIVER", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLUPSIDEGAP2CROWS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CDLXSIDEGAP3METHODS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CEIL", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CHANDELIER_EXIT", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CHOPPINESS_INDEX", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CMO", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "CORREL", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "COS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "COSH", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "DEMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "DIV", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "DONCHIAN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "DX", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "EMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "EXP", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "FLOOR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "HT_DCPERIOD", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "HT_DCPHASE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "HT_PHASOR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "HT_SINE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "HT_TRENDLINE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "HT_TRENDMODE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "HULL_MA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ICHIMOKU", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "KAMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "KELTNER_CHANNELS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_ANGLE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_INTERCEPT", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "LINEARREG_SLOPE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "LN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "LOG10", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MACD", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MACDEXT", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MACDFIX", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MAMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MAVP", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MAX", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MAXINDEX", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MEDPRICE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MFI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MIDPOINT", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MIDPRICE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MIN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MININDEX", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MINUS_DI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MINUS_DM", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MOM", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "MULT", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "NATR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "OBV", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "PIVOT_POINTS", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "PLUS_DI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "PLUS_DM", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "PPO", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ROC", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ROCP", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ROCR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ROCR100", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "RSI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SAREXT", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SIN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SINH", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SQRT", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "STDDEV", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "STOCH", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "STOCHF", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "STOCHRSI", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SUB", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SUM", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "SUPERTREND", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "T3", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "TAN", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "TANH", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "TEMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "TRANGE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "TRIMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "TRIX", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "TSF", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "TYPPRICE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "ULTOSC", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "VAR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "VWAP", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "VWMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "WCLPRICE", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "WILLR", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "WMA", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "__version__", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "about", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "benchmark", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "debug_mode", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "disable_debug", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "enable_debug", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "get_logger", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "indicators", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "info", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "log_call", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "methods", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "traced", - "category": "top_level", - "module": "ferro_ta", - "doc": "", - "params": [] - }, - { - "name": "plot", - "category": "viz", - "module": "ferro_ta.tools.viz", - "doc": "", - "params": [] - }, - { - "name": "ATR", - "category": "volatility", - "module": "ferro_ta.indicators.volatility", - "doc": "", - "params": [] - }, - { - "name": "NATR", - "category": "volatility", - "module": "ferro_ta.indicators.volatility", - "doc": "", - "params": [] - }, - { - "name": "TRANGE", - "category": "volatility", - "module": "ferro_ta.indicators.volatility", - "doc": "", - "params": [] - }, - { - "name": "AD", - "category": "volume", - "module": "ferro_ta.indicators.volume", - "doc": "", - "params": [] - }, - { - "name": "ADOSC", - "category": "volume", - "module": "ferro_ta.indicators.volume", - "doc": "", - "params": [] - }, - { - "name": "OBV", - "category": "volume", - "module": "ferro_ta.indicators.volume", - "doc": "", - "params": [] - } - ] - }, - "rust_core": { - "public_function_count": 64, - "functions": [ - { - "module": "futures.basis", - "function": "annualized_basis", - "file": "futures/basis.rs" - }, - { - "module": "futures.basis", - "function": "basis", - "file": "futures/basis.rs" - }, - { - "module": "futures.basis", - "function": "carry_spread", - "file": "futures/basis.rs" - }, - { - "module": "futures.basis", - "function": "implied_carry_rate", - "file": "futures/basis.rs" - }, - { - "module": "futures.curve", - "function": "calendar_spreads", - "file": "futures/curve.rs" - }, - { - "module": "futures.curve", - "function": "curve_slope", - "file": "futures/curve.rs" - }, - { - "module": "futures.curve", - "function": "curve_summary", - "file": "futures/curve.rs" - }, - { - "module": "futures.roll", - "function": "back_adjusted_continuous", - "file": "futures/roll.rs" - }, - { - "module": "futures.roll", - "function": "ratio_adjusted_continuous", - "file": "futures/roll.rs" - }, - { - "module": "futures.roll", - "function": "roll_yield", - "file": "futures/roll.rs" - }, - { - "module": "futures.roll", - "function": "weighted_continuous", - "file": "futures/roll.rs" - }, - { - "module": "futures.synthetic", - "function": "parity_gap", - "file": "futures/synthetic.rs" - }, - { - "module": "futures.synthetic", - "function": "synthetic_forward", - "file": "futures/synthetic.rs" - }, - { - "module": "futures.synthetic", - "function": "synthetic_spot", - "file": "futures/synthetic.rs" - }, - { - "module": "math", - "function": "max", - "file": "math.rs" - }, - { - "module": "math", - "function": "min", - "file": "math.rs" - }, - { - "module": "math", - "function": "sliding_max", - "file": "math.rs" - }, - { - "module": "math", - "function": "sliding_min", - "file": "math.rs" - }, - { - "module": "math", - "function": "sum", - "file": "math.rs" - }, - { - "module": "momentum", - "function": "adx", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "adxr", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "dx", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "minus_di", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "minus_dm", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "mom", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "plus_di", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "plus_dm", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "rsi", - "file": "momentum.rs" - }, - { - "module": "momentum", - "function": "stoch", - "file": "momentum.rs" - }, - { - "module": "options.chain", - "function": "atm_index", - "file": "options/chain.rs" - }, - { - "module": "options.chain", - "function": "label_moneyness", - "file": "options/chain.rs" - }, - { - "module": "options.chain", - "function": "select_strike_by_delta", - "file": "options/chain.rs" - }, - { - "module": "options.chain", - "function": "select_strike_by_offset", - "file": "options/chain.rs" - }, - { - "module": "options.greeks", - "function": "black_76_greeks", - "file": "options/greeks.rs" - }, - { - "module": "options.greeks", - "function": "black_scholes_greeks", - "file": "options/greeks.rs" - }, - { - "module": "options.greeks", - "function": "model_greeks", - "file": "options/greeks.rs" - }, - { - "module": "options.greeks", - "function": "model_theta", - "file": "options/greeks.rs" - }, - { - "module": "options.iv", - "function": "implied_volatility", - "file": "options/iv.rs" - }, - { - "module": "options.iv", - "function": "iv_percentile", - "file": "options/iv.rs" - }, - { - "module": "options.iv", - "function": "iv_rank", - "file": "options/iv.rs" - }, - { - "module": "options.iv", - "function": "iv_zscore", - "file": "options/iv.rs" - }, - { - "module": "options.mod", - "function": "sign", - "file": "options/mod.rs" - }, - { - "module": "options.normal", - "function": "cdf", - "file": "options/normal.rs" - }, - { - "module": "options.normal", - "function": "pdf", - "file": "options/normal.rs" - }, - { - "module": "options.pricing", - "function": "black_76_price", - "file": "options/pricing.rs" - }, - { - "module": "options.pricing", - "function": "black_scholes_price", - "file": "options/pricing.rs" - }, - { - "module": "options.pricing", - "function": "model_price", - "file": "options/pricing.rs" - }, - { - "module": "options.pricing", - "function": "price_lower_bound", - "file": "options/pricing.rs" - }, - { - "module": "options.pricing", - "function": "price_upper_bound", - "file": "options/pricing.rs" - }, - { - "module": "options.surface", - "function": "atm_iv", - "file": "options/surface.rs" - }, - { - "module": "options.surface", - "function": "linear_interpolate", - "file": "options/surface.rs" - }, - { - "module": "options.surface", - "function": "smile_metrics", - "file": "options/surface.rs" - }, - { - "module": "options.surface", - "function": "term_structure_slope", - "file": "options/surface.rs" - }, - { - "module": "overlap", - "function": "bbands", - "file": "overlap.rs" - }, - { - "module": "overlap", - "function": "ema", - "file": "overlap.rs" - }, - { - "module": "overlap", - "function": "macd", - "file": "overlap.rs" - }, - { - "module": "overlap", - "function": "sma", - "file": "overlap.rs" - }, - { - "module": "overlap", - "function": "sma_into", - "file": "overlap.rs" - }, - { - "module": "overlap", - "function": "wma", - "file": "overlap.rs" - }, - { - "module": "statistic", - "function": "stddev", - "file": "statistic.rs" - }, - { - "module": "volatility", - "function": "atr", - "file": "volatility.rs" - }, - { - "module": "volatility", - "function": "trange", - "file": "volatility.rs" - }, - { - "module": "volume", - "function": "mfi", - "file": "volume.rs" - }, - { - "module": "volume", - "function": "obv", - "file": "volume.rs" - } - ] - }, - "wasm_node": { - "export_count": 12, - "exports": [ - "adx", - "atr", - "bbands", - "ema", - "macd", - "mfi", - "mom", - "obv", - "rsi", - "sma", - "stochf", - "wma" - ] - } - }, "parity_summary": { - "python_indicator_count": 207, - "wasm_export_count": 12, - "common_python_wasm_count": 12, "common_python_wasm": [ + "ad", + "adosc", "adx", "atr", + "avgprice", "bbands", + "chandelier_exit", + "check_cross", + "check_threshold", + "choppiness_index", + "collect_alert_bars", + "continuous_bar_labels", + "detect_breaks_cusum", + "donchian", "ema", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendline", + "ht_trendmode", + "hull_ma", + "ichimoku", + "keltner_channels", "macd", + "medprice", "mfi", "mom", "obv", + "pivot_points", + "regime_adx", + "regime_combined", + "rolling_variance_break", "rsi", "sma", "stochf", + "supertrend", + "typprice", + "volume_bars", + "vwap", + "vwma", + "wclprice", "wma" ], + "common_python_wasm_count": 43, + "python_indicator_count": 207, "python_only_vs_wasm": [ "acos", - "ad", "add", - "adosc", "adxr", "aggregate_ticks", "alertevent", @@ -4990,7 +59,6 @@ "aroonosc", "asin", "atan", - "avgprice", "batch_apply", "batch_ema", "batch_rsi", @@ -5060,39 +128,22 @@ "cdlupsidegap2crows", "cdlxsidegap3methods", "ceil", - "chandelier_exit", - "check_cross", - "check_threshold", - "choppiness_index", "cmo", - "collect_alert_bars", "compose", "compute_many", - "continuous_bar_labels", "correl", "correlation_matrix", "cos", "cosh", "dema", - "detect_breaks_cusum", "div", - "donchian", "drawdown", "dx", "exp", "feature_matrix", "floor", "funding_pnl", - "ht_dcperiod", - "ht_dcphase", - "ht_phasor", - "ht_sine", - "ht_trendline", - "ht_trendmode", - "hull_ma", - "ichimoku", "kama", - "keltner_channels", "linearreg", "linearreg_angle", "linearreg_intercept", @@ -5106,7 +157,6 @@ "mavp", "max", "maxindex", - "medprice", "midpoint", "midprice", "min", @@ -5116,22 +166,18 @@ "mult", "multi_timeframe", "natr", - "pivot_points", "plus_di", "plus_dm", "portfolio_volatility", "ppo", "rank_signals", "regime", - "regime_adx", - "regime_combined", "resample", "resample_continuous", "roc", "rocp", "rocr", "rocr100", - "rolling_variance_break", "sar", "sarext", "screen", @@ -5154,7 +200,6 @@ "structural_breaks", "sub", "sum", - "supertrend", "t3", "tan", "tanh", @@ -5164,15 +209,6231 @@ "trima", "trix", "tsf", - "typprice", "ultosc", "var", - "volume_bars", - "vwap", - "vwma", - "wclprice", "willr" ], - "wasm_only_vs_python": [] + "wasm_export_count": 95, + "wasm_only_vs_python": [ + "aggregate_tick_bars", + "aggregate_time_bars", + "aggregate_volume_bars_ticks", + "beta_full", + "bottom_n_indices", + "drawdown_series", + "exchange_charges_rate", + "extract_trades", + "flat_per_order", + "forward_fill_nan", + "funding_cumulative_pnl", + "gst_rate", + "lot_size", + "make_chunk_ranges", + "max_brokerage", + "monthly_contribution", + "new", + "ohlcv_agg", + "per_lot", + "rank_series", + "rate_of_value", + "ratio", + "regulatory_charges_rate", + "relative_strength", + "rolling_beta", + "rolling_max", + "rolling_maxindex", + "rolling_min", + "rolling_minindex", + "rolling_sum", + "set_exchange_charges_rate", + "set_flat_per_order", + "set_gst_rate", + "set_lot_size", + "set_max_brokerage", + "set_per_lot", + "set_rate_of_value", + "set_regulatory_charges_rate", + "set_stamp_duty_rate", + "set_stt_on_buy", + "set_stt_on_sell", + "set_stt_rate", + "signal_attribution", + "spread", + "stamp_duty_rate", + "stt_on_buy", + "stt_on_sell", + "stt_rate", + "top_n_indices", + "trade_stats", + "trim_overlap", + "zscore_series" + ] + }, + "surfaces": { + "python": { + "categories": [ + "aggregation", + "alerts", + "batch", + "crypto", + "cycle", + "extended", + "features", + "math_ops", + "momentum", + "overlap", + "pattern", + "portfolio", + "price_transform", + "regime", + "resampling", + "signals", + "statistic", + "streaming", + "volatility", + "volume" + ], + "indicator_count": 208, + "indicators": [ + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "ACOS", + "params": [] + }, + { + "category": "volume", + "doc": "", + "module": "ferro_ta.indicators.volume", + "name": "AD", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "ADD", + "params": [] + }, + { + "category": "volume", + "doc": "", + "module": "ferro_ta.indicators.volume", + "name": "ADOSC", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ADX", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ADXR", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "APO", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "AROON", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "AROONOSC", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "ASIN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "ATAN", + "params": [] + }, + { + "category": "volatility", + "doc": "", + "module": "ferro_ta.indicators.volatility", + "name": "ATR", + "params": [] + }, + { + "category": "price_transform", + "doc": "", + "module": "ferro_ta.indicators.price_transform", + "name": "AVGPRICE", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "AlertEvent", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "AlertManager", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "BBANDS", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "BETA", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "BOP", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "CCI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL2CROWS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3BLACKCROWS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3INSIDE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3LINESTRIKE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3OUTSIDE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3STARSINSOUTH", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3WHITESOLDIERS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLABANDONEDBABY", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLADVANCEBLOCK", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLBELTHOLD", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLBREAKAWAY", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLCLOSINGMARUBOZU", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLCONCEALBABYSWALL", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLCOUNTERATTACK", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLDARKCLOUDCOVER", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLDOJI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLDOJISTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLDRAGONFLYDOJI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLENGULFING", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLEVENINGDOJISTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLEVENINGSTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLGAPSIDESIDEWHITE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLGRAVESTONEDOJI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHAMMER", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHANGINGMAN", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHARAMI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHARAMICROSS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHIGHWAVE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHIKKAKE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHIKKAKEMOD", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHOMINGPIGEON", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLIDENTICAL3CROWS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLINNECK", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLINVERTEDHAMMER", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLKICKING", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLKICKINGBYLENGTH", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLLADDERBOTTOM", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLLONGLEGGEDDOJI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLLONGLINE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMARUBOZU", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMATCHINGLOW", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMATHOLD", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMORNINGDOJISTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMORNINGSTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLONNECK", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLPIERCING", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLRICKSHAWMAN", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLRISEFALL3METHODS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSEPARATINGLINES", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSHOOTINGSTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSHORTLINE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSPINNINGTOP", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSTALLEDPATTERN", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSTICKSANDWICH", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLTAKURI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLTASUKIGAP", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLTHRUSTING", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLTRISTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLUNIQUE3RIVER", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLUPSIDEGAP2CROWS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLXSIDEGAP3METHODS", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "CEIL", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "CHANDELIER_EXIT", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "CHOPPINESS_INDEX", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "CMO", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "CORREL", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "COS", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "COSH", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "DEMA", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "DIV", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "DONCHIAN", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "DX", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "EMA", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "EXP", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "FLOOR", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_DCPERIOD", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_DCPHASE", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_PHASOR", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_SINE", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_TRENDLINE", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_TRENDMODE", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "HULL_MA", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "ICHIMOKU", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "KAMA", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "KELTNER_CHANNELS", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "LINEARREG", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "LINEARREG_ANGLE", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "LINEARREG_INTERCEPT", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "LINEARREG_SLOPE", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "LN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "LOG10", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MACD", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MACDEXT", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MACDFIX", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MAMA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MAVP", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MAX", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MAXINDEX", + "params": [] + }, + { + "category": "price_transform", + "doc": "", + "module": "ferro_ta.indicators.price_transform", + "name": "MEDPRICE", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "MFI", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MIDPOINT", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MIDPRICE", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MIN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MININDEX", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "MINUS_DI", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "MINUS_DM", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "MOM", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MULT", + "params": [] + }, + { + "category": "volatility", + "doc": "", + "module": "ferro_ta.indicators.volatility", + "name": "NATR", + "params": [] + }, + { + "category": "volume", + "doc": "", + "module": "ferro_ta.indicators.volume", + "name": "OBV", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "PIVOT_POINTS", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "PLUS_DI", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "PLUS_DM", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "PPO", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ROC", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ROCP", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ROCR", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ROCR100", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "RSI", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "SAR", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "SAREXT", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SIN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SINH", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "SMA", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SQRT", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "STDDEV", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "STOCH", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "STOCHF", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "STOCHRSI", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SUB", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SUM", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "SUPERTREND", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingATR", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingBBands", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingEMA", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingMACD", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingRSI", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingSMA", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingStoch", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingSupertrend", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingVWAP", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "T3", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "TAN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "TANH", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "TEMA", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "TRANGE", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "TRIMA", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "TRIX", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "TSF", + "params": [] + }, + { + "category": "price_transform", + "doc": "", + "module": "ferro_ta.indicators.price_transform", + "name": "TYPPRICE", + "params": [] + }, + { + "category": "aggregation", + "doc": "", + "module": "ferro_ta.data.aggregation", + "name": "TickAggregator", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ULTOSC", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "VAR", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "VWAP", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "VWMA", + "params": [] + }, + { + "category": "price_transform", + "doc": "", + "module": "ferro_ta.indicators.price_transform", + "name": "WCLPRICE", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "WILLR", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "WMA", + "params": [] + }, + { + "category": "aggregation", + "doc": "", + "module": "ferro_ta.data.aggregation", + "name": "aggregate_ticks", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "batch_apply", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "batch_ema", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "batch_rsi", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "batch_sma", + "params": [] + }, + { + "category": "portfolio", + "doc": "", + "module": "ferro_ta.analysis.portfolio", + "name": "beta", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "check_cross", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "check_threshold", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "collect_alert_bars", + "params": [] + }, + { + "category": "signals", + "doc": "", + "module": "ferro_ta.analysis.signals", + "name": "compose", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "compute_many", + "params": [] + }, + { + "category": "crypto", + "doc": "", + "module": "ferro_ta.analysis.crypto", + "name": "continuous_bar_labels", + "params": [] + }, + { + "category": "portfolio", + "doc": "", + "module": "ferro_ta.analysis.portfolio", + "name": "correlation_matrix", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "detect_breaks_cusum", + "params": [] + }, + { + "category": "portfolio", + "doc": "", + "module": "ferro_ta.analysis.portfolio", + "name": "drawdown", + "params": [] + }, + { + "category": "features", + "doc": "", + "module": "ferro_ta.analysis.features", + "name": "feature_matrix", + "params": [] + }, + { + "category": "crypto", + "doc": "", + "module": "ferro_ta.analysis.crypto", + "name": "funding_pnl", + "params": [] + }, + { + "category": "resampling", + "doc": "", + "module": "ferro_ta.data.resampling", + "name": "multi_timeframe", + "params": [] + }, + { + "category": "portfolio", + "doc": "", + "module": "ferro_ta.analysis.portfolio", + "name": "portfolio_volatility", + "params": [] + }, + { + "category": "signals", + "doc": "", + "module": "ferro_ta.analysis.signals", + "name": "rank_signals", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "regime", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "regime_adx", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "regime_combined", + "params": [] + }, + { + "category": "resampling", + "doc": "", + "module": "ferro_ta.data.resampling", + "name": "resample", + "params": [] + }, + { + "category": "crypto", + "doc": "", + "module": "ferro_ta.analysis.crypto", + "name": "resample_continuous", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "rolling_variance_break", + "params": [] + }, + { + "category": "signals", + "doc": "", + "module": "ferro_ta.analysis.signals", + "name": "screen", + "params": [] + }, + { + "category": "crypto", + "doc": "", + "module": "ferro_ta.analysis.crypto", + "name": "session_boundaries", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "structural_breaks", + "params": [] + }, + { + "category": "resampling", + "doc": "", + "module": "ferro_ta.data.resampling", + "name": "volume_bars", + "params": [] + } + ], + "method_count": 447, + "methods": [ + { + "category": "aggregation", + "doc": "", + "module": "ferro_ta.data.aggregation", + "name": "TickAggregator", + "params": [] + }, + { + "category": "aggregation", + "doc": "", + "module": "ferro_ta.data.aggregation", + "name": "aggregate_ticks", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "AlertEvent", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "AlertManager", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "check_cross", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "check_threshold", + "params": [] + }, + { + "category": "alerts", + "doc": "", + "module": "ferro_ta.tools.alerts", + "name": "collect_alert_bars", + "params": [] + }, + { + "category": "attribution", + "doc": "", + "module": "ferro_ta.analysis.attribution", + "name": "TradeStats", + "params": [] + }, + { + "category": "attribution", + "doc": "", + "module": "ferro_ta.analysis.attribution", + "name": "attribution_by_month", + "params": [] + }, + { + "category": "attribution", + "doc": "", + "module": "ferro_ta.analysis.attribution", + "name": "attribution_by_signal", + "params": [] + }, + { + "category": "attribution", + "doc": "", + "module": "ferro_ta.analysis.attribution", + "name": "from_backtest", + "params": [] + }, + { + "category": "attribution", + "doc": "", + "module": "ferro_ta.analysis.attribution", + "name": "trade_stats", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "batch_apply", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "batch_ema", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "batch_rsi", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "batch_sma", + "params": [] + }, + { + "category": "batch", + "doc": "", + "module": "ferro_ta.data.batch", + "name": "compute_many", + "params": [] + }, + { + "category": "cross_asset", + "doc": "", + "module": "ferro_ta.analysis.cross_asset", + "name": "ratio", + "params": [] + }, + { + "category": "cross_asset", + "doc": "", + "module": "ferro_ta.analysis.cross_asset", + "name": "relative_strength", + "params": [] + }, + { + "category": "cross_asset", + "doc": "", + "module": "ferro_ta.analysis.cross_asset", + "name": "rolling_beta", + "params": [] + }, + { + "category": "cross_asset", + "doc": "", + "module": "ferro_ta.analysis.cross_asset", + "name": "spread", + "params": [] + }, + { + "category": "cross_asset", + "doc": "", + "module": "ferro_ta.analysis.cross_asset", + "name": "zscore", + "params": [] + }, + { + "category": "crypto", + "doc": "", + "module": "ferro_ta.analysis.crypto", + "name": "continuous_bar_labels", + "params": [] + }, + { + "category": "crypto", + "doc": "", + "module": "ferro_ta.analysis.crypto", + "name": "funding_pnl", + "params": [] + }, + { + "category": "crypto", + "doc": "", + "module": "ferro_ta.analysis.crypto", + "name": "resample_continuous", + "params": [] + }, + { + "category": "crypto", + "doc": "", + "module": "ferro_ta.analysis.crypto", + "name": "session_boundaries", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_DCPERIOD", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_DCPHASE", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_PHASOR", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_SINE", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_TRENDLINE", + "params": [] + }, + { + "category": "cycle", + "doc": "", + "module": "ferro_ta.indicators.cycle", + "name": "HT_TRENDMODE", + "params": [] + }, + { + "category": "derivatives_payoff", + "doc": "", + "module": "ferro_ta.analysis.derivatives_payoff", + "name": "PayoffLeg", + "params": [] + }, + { + "category": "derivatives_payoff", + "doc": "", + "module": "ferro_ta.analysis.derivatives_payoff", + "name": "aggregate_greeks", + "params": [] + }, + { + "category": "derivatives_payoff", + "doc": "", + "module": "ferro_ta.analysis.derivatives_payoff", + "name": "futures_leg_payoff", + "params": [] + }, + { + "category": "derivatives_payoff", + "doc": "", + "module": "ferro_ta.analysis.derivatives_payoff", + "name": "option_leg_payoff", + "params": [] + }, + { + "category": "derivatives_payoff", + "doc": "", + "module": "ferro_ta.analysis.derivatives_payoff", + "name": "strategy_payoff", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "CHANDELIER_EXIT", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "CHOPPINESS_INDEX", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "DONCHIAN", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "HULL_MA", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "ICHIMOKU", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "KELTNER_CHANNELS", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "PIVOT_POINTS", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "SUPERTREND", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "VWAP", + "params": [] + }, + { + "category": "extended", + "doc": "", + "module": "ferro_ta.indicators.extended", + "name": "VWMA", + "params": [] + }, + { + "category": "features", + "doc": "", + "module": "ferro_ta.analysis.features", + "name": "feature_matrix", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "CurveSummary", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "annualized_basis", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "back_adjusted_continuous_contract", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "basis", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "calendar_spreads", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "carry_spread", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "curve_slope", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "curve_summary", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "implied_carry_rate", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "parity_gap", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "ratio_adjusted_continuous_contract", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "roll_yield", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "synthetic_forward", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "synthetic_spot", + "params": [] + }, + { + "category": "futures", + "doc": "", + "module": "ferro_ta.analysis.futures", + "name": "weighted_continuous_contract", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "ACOS", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "ADD", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "ASIN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "ATAN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "CEIL", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "COS", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "COSH", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "DIV", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "EXP", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "FLOOR", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "LN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "LOG10", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MAX", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MAXINDEX", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MIN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MININDEX", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "MULT", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SIN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SINH", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SQRT", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SUB", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "SUM", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "TAN", + "params": [] + }, + { + "category": "math_ops", + "doc": "", + "module": "ferro_ta.indicators.math_ops", + "name": "TANH", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ADX", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ADXR", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "APO", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "AROON", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "AROONOSC", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "BOP", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "CCI", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "CMO", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "DX", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "MFI", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "MINUS_DI", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "MINUS_DM", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "MOM", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "PLUS_DI", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "PLUS_DM", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "PPO", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ROC", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ROCP", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ROCR", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ROCR100", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "RSI", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "STOCH", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "STOCHF", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "STOCHRSI", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "TRANGE", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "TRIX", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "ULTOSC", + "params": [] + }, + { + "category": "momentum", + "doc": "", + "module": "ferro_ta.indicators.momentum", + "name": "WILLR", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "OptionGreeks", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "SmileMetrics", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "black_76_price", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "black_scholes_price", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "greeks", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "implied_volatility", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "iv_percentile", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "iv_rank", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "iv_zscore", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "label_moneyness", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "option_price", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "select_strike", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "smile_metrics", + "params": [] + }, + { + "category": "options", + "doc": "", + "module": "ferro_ta.analysis.options", + "name": "term_structure_slope", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "DerivativesStrategy", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "ExpirySelector", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "ExpirySelectorKind", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "LegPreset", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "RiskControl", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "RiskMode", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "SimulationLimits", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "StrategyLeg", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "StrikeSelector", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "StrikeSelectorKind", + "params": [] + }, + { + "category": "options_strategy", + "doc": "", + "module": "ferro_ta.analysis.options_strategy", + "name": "build_strategy_preset", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "BBANDS", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "DEMA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "EMA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "KAMA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MACD", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MACDEXT", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MACDFIX", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MAMA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MAVP", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MIDPOINT", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "MIDPRICE", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "SAR", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "SAREXT", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "SMA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "T3", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "TEMA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "TRIMA", + "params": [] + }, + { + "category": "overlap", + "doc": "", + "module": "ferro_ta.indicators.overlap", + "name": "WMA", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL2CROWS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3BLACKCROWS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3INSIDE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3LINESTRIKE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3OUTSIDE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3STARSINSOUTH", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDL3WHITESOLDIERS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLABANDONEDBABY", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLADVANCEBLOCK", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLBELTHOLD", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLBREAKAWAY", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLCLOSINGMARUBOZU", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLCONCEALBABYSWALL", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLCOUNTERATTACK", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLDARKCLOUDCOVER", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLDOJI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLDOJISTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLDRAGONFLYDOJI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLENGULFING", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLEVENINGDOJISTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLEVENINGSTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLGAPSIDESIDEWHITE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLGRAVESTONEDOJI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHAMMER", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHANGINGMAN", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHARAMI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHARAMICROSS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHIGHWAVE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHIKKAKE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHIKKAKEMOD", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLHOMINGPIGEON", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLIDENTICAL3CROWS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLINNECK", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLINVERTEDHAMMER", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLKICKING", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLKICKINGBYLENGTH", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLLADDERBOTTOM", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLLONGLEGGEDDOJI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLLONGLINE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMARUBOZU", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMATCHINGLOW", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMATHOLD", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMORNINGDOJISTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLMORNINGSTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLONNECK", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLPIERCING", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLRICKSHAWMAN", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLRISEFALL3METHODS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSEPARATINGLINES", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSHOOTINGSTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSHORTLINE", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSPINNINGTOP", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSTALLEDPATTERN", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLSTICKSANDWICH", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLTAKURI", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLTASUKIGAP", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLTHRUSTING", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLTRISTAR", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLUNIQUE3RIVER", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLUPSIDEGAP2CROWS", + "params": [] + }, + { + "category": "pattern", + "doc": "", + "module": "ferro_ta.indicators.pattern", + "name": "CDLXSIDEGAP3METHODS", + "params": [] + }, + { + "category": "portfolio", + "doc": "", + "module": "ferro_ta.analysis.portfolio", + "name": "beta", + "params": [] + }, + { + "category": "portfolio", + "doc": "", + "module": "ferro_ta.analysis.portfolio", + "name": "correlation_matrix", + "params": [] + }, + { + "category": "portfolio", + "doc": "", + "module": "ferro_ta.analysis.portfolio", + "name": "drawdown", + "params": [] + }, + { + "category": "portfolio", + "doc": "", + "module": "ferro_ta.analysis.portfolio", + "name": "portfolio_volatility", + "params": [] + }, + { + "category": "price_transform", + "doc": "", + "module": "ferro_ta.indicators.price_transform", + "name": "AVGPRICE", + "params": [] + }, + { + "category": "price_transform", + "doc": "", + "module": "ferro_ta.indicators.price_transform", + "name": "MEDPRICE", + "params": [] + }, + { + "category": "price_transform", + "doc": "", + "module": "ferro_ta.indicators.price_transform", + "name": "TYPPRICE", + "params": [] + }, + { + "category": "price_transform", + "doc": "", + "module": "ferro_ta.indicators.price_transform", + "name": "WCLPRICE", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "detect_breaks_cusum", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "regime", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "regime_adx", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "regime_combined", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "rolling_variance_break", + "params": [] + }, + { + "category": "regime", + "doc": "", + "module": "ferro_ta.analysis.regime", + "name": "structural_breaks", + "params": [] + }, + { + "category": "resampling", + "doc": "", + "module": "ferro_ta.data.resampling", + "name": "multi_timeframe", + "params": [] + }, + { + "category": "resampling", + "doc": "", + "module": "ferro_ta.data.resampling", + "name": "resample", + "params": [] + }, + { + "category": "resampling", + "doc": "", + "module": "ferro_ta.data.resampling", + "name": "volume_bars", + "params": [] + }, + { + "category": "signals", + "doc": "", + "module": "ferro_ta.analysis.signals", + "name": "compose", + "params": [] + }, + { + "category": "signals", + "doc": "", + "module": "ferro_ta.analysis.signals", + "name": "rank_signals", + "params": [] + }, + { + "category": "signals", + "doc": "", + "module": "ferro_ta.analysis.signals", + "name": "screen", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "BETA", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "CORREL", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "LINEARREG", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "LINEARREG_ANGLE", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "LINEARREG_INTERCEPT", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "LINEARREG_SLOPE", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "STDDEV", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "TSF", + "params": [] + }, + { + "category": "statistic", + "doc": "", + "module": "ferro_ta.indicators.statistic", + "name": "VAR", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingATR", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingBBands", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingEMA", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingMACD", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingRSI", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingSMA", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingStoch", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingSupertrend", + "params": [] + }, + { + "category": "streaming", + "doc": "", + "module": "ferro_ta.data.streaming", + "name": "StreamingVWAP", + "params": [] + }, + { + "category": "tools", + "doc": "", + "module": "ferro_ta.tools.tools", + "name": "compute_indicator", + "params": [] + }, + { + "category": "tools", + "doc": "", + "module": "ferro_ta.tools.tools", + "name": "describe_indicator", + "params": [] + }, + { + "category": "tools", + "doc": "", + "module": "ferro_ta.tools.tools", + "name": "list_indicators", + "params": [] + }, + { + "category": "tools", + "doc": "", + "module": "ferro_ta.tools.tools", + "name": "run_backtest", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ACOS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "AD", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ADD", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ADOSC", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ADX", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ADXR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "APO", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "AROON", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "AROONOSC", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ASIN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ATAN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ATR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "AVGPRICE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "BBANDS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "BETA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "BOP", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CCI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDL2CROWS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDL3BLACKCROWS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDL3INSIDE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDL3LINESTRIKE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDL3OUTSIDE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDL3STARSINSOUTH", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDL3WHITESOLDIERS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLABANDONEDBABY", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLADVANCEBLOCK", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLBELTHOLD", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLBREAKAWAY", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLCLOSINGMARUBOZU", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLCONCEALBABYSWALL", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLCOUNTERATTACK", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLDARKCLOUDCOVER", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLDOJI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLDOJISTAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLDRAGONFLYDOJI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLENGULFING", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLEVENINGDOJISTAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLEVENINGSTAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLGAPSIDESIDEWHITE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLGRAVESTONEDOJI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLHAMMER", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLHANGINGMAN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLHARAMI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLHARAMICROSS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLHIGHWAVE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLHIKKAKE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLHIKKAKEMOD", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLHOMINGPIGEON", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLIDENTICAL3CROWS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLINNECK", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLINVERTEDHAMMER", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLKICKING", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLKICKINGBYLENGTH", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLLADDERBOTTOM", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLLONGLEGGEDDOJI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLLONGLINE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLMARUBOZU", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLMATCHINGLOW", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLMATHOLD", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLMORNINGDOJISTAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLMORNINGSTAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLONNECK", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLPIERCING", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLRICKSHAWMAN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLRISEFALL3METHODS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLSEPARATINGLINES", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLSHOOTINGSTAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLSHORTLINE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLSPINNINGTOP", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLSTALLEDPATTERN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLSTICKSANDWICH", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLTAKURI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLTASUKIGAP", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLTHRUSTING", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLTRISTAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLUNIQUE3RIVER", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLUPSIDEGAP2CROWS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CDLXSIDEGAP3METHODS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CEIL", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CHANDELIER_EXIT", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CHOPPINESS_INDEX", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CMO", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "CORREL", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "COS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "COSH", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "DEMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "DIV", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "DONCHIAN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "DX", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "EMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "EXP", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "FLOOR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "HT_DCPERIOD", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "HT_DCPHASE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "HT_PHASOR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "HT_SINE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "HT_TRENDLINE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "HT_TRENDMODE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "HULL_MA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ICHIMOKU", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "KAMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "KELTNER_CHANNELS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "LINEARREG", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "LINEARREG_ANGLE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "LINEARREG_INTERCEPT", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "LINEARREG_SLOPE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "LN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "LOG10", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MACD", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MACDEXT", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MACDFIX", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MAMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MAVP", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MAX", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MAXINDEX", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MEDPRICE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MFI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MIDPOINT", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MIDPRICE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MIN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MININDEX", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MINUS_DI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MINUS_DM", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MOM", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "MULT", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "NATR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "OBV", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "PIVOT_POINTS", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "PLUS_DI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "PLUS_DM", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "PPO", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ROC", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ROCP", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ROCR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ROCR100", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "RSI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SAREXT", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SIN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SINH", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SQRT", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "STDDEV", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "STOCH", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "STOCHF", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "STOCHRSI", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SUB", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SUM", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "SUPERTREND", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "T3", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "TAN", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "TANH", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "TEMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "TRANGE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "TRIMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "TRIX", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "TSF", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "TYPPRICE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "ULTOSC", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "VAR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "VWAP", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "VWMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "WCLPRICE", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "WILLR", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "WMA", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "__version__", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "about", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "benchmark", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "debug_mode", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "disable_debug", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "enable_debug", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "get_logger", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "indicators", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "info", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "log_call", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "methods", + "params": [] + }, + { + "category": "top_level", + "doc": "", + "module": "ferro_ta", + "name": "traced", + "params": [] + }, + { + "category": "viz", + "doc": "", + "module": "ferro_ta.tools.viz", + "name": "plot", + "params": [] + }, + { + "category": "volatility", + "doc": "", + "module": "ferro_ta.indicators.volatility", + "name": "ATR", + "params": [] + }, + { + "category": "volatility", + "doc": "", + "module": "ferro_ta.indicators.volatility", + "name": "NATR", + "params": [] + }, + { + "category": "volatility", + "doc": "", + "module": "ferro_ta.indicators.volatility", + "name": "TRANGE", + "params": [] + }, + { + "category": "volume", + "doc": "", + "module": "ferro_ta.indicators.volume", + "name": "AD", + "params": [] + }, + { + "category": "volume", + "doc": "", + "module": "ferro_ta.indicators.volume", + "name": "ADOSC", + "params": [] + }, + { + "category": "volume", + "doc": "", + "module": "ferro_ta.indicators.volume", + "name": "OBV", + "params": [] + } + ] + }, + "rust_core": { + "functions": [ + { + "file": "aggregation.rs", + "function": "aggregate_tick_bars", + "module": "aggregation" + }, + { + "file": "aggregation.rs", + "function": "aggregate_time_bars", + "module": "aggregation" + }, + { + "file": "aggregation.rs", + "function": "aggregate_volume_bars_ticks", + "module": "aggregation" + }, + { + "file": "alerts.rs", + "function": "check_cross", + "module": "alerts" + }, + { + "file": "alerts.rs", + "function": "check_threshold", + "module": "alerts" + }, + { + "file": "alerts.rs", + "function": "collect_alert_bars", + "module": "alerts" + }, + { + "file": "attribution.rs", + "function": "extract_trades", + "module": "attribution" + }, + { + "file": "attribution.rs", + "function": "monthly_contribution", + "module": "attribution" + }, + { + "file": "attribution.rs", + "function": "signal_attribution", + "module": "attribution" + }, + { + "file": "attribution.rs", + "function": "trade_stats", + "module": "attribution" + }, + { + "file": "backtest.rs", + "function": "backtest_core", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "backtest_multi_asset_core", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "backtest_ohlcv_core", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "close_position", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "commission_fraction", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "compute_performance_metrics", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "extract_trades_ohlcv", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "half_kelly_fraction", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "kelly_formula", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "kelly_fraction", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "lcg_index", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "lcg_next", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "macd_crossover_signals", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "monte_carlo_bootstrap", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "nan_to_num", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "new", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "new", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "on_bar", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "reset", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "resolve_commission_model", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "rsi_threshold_signals", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "single_asset_backtest", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "sma_crossover_signals", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "summary", + "module": "backtest" + }, + { + "file": "backtest.rs", + "function": "walk_forward_indices", + "module": "backtest" + }, + { + "file": "batch.rs", + "function": "batch_adx", + "module": "batch" + }, + { + "file": "batch.rs", + "function": "batch_atr", + "module": "batch" + }, + { + "file": "batch.rs", + "function": "batch_ema", + "module": "batch" + }, + { + "file": "batch.rs", + "function": "batch_rsi", + "module": "batch" + }, + { + "file": "batch.rs", + "function": "batch_sma", + "module": "batch" + }, + { + "file": "batch.rs", + "function": "batch_stoch", + "module": "batch" + }, + { + "file": "batch.rs", + "function": "run_close_indicators", + "module": "batch" + }, + { + "file": "batch.rs", + "function": "run_hlc_indicators", + "module": "batch" + }, + { + "file": "chunked.rs", + "function": "forward_fill_nan", + "module": "chunked" + }, + { + "file": "chunked.rs", + "function": "make_chunk_ranges", + "module": "chunked" + }, + { + "file": "chunked.rs", + "function": "stitch_chunks", + "module": "chunked" + }, + { + "file": "chunked.rs", + "function": "trim_overlap", + "module": "chunked" + }, + { + "file": "commission.rs", + "function": "cost_fraction", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "equity_delivery_india", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "equity_intraday_india", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "from_json", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "futures_india", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "options_india", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "proportional", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "short_borrow_cost", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "to_json", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "total_cost", + "module": "commission" + }, + { + "file": "commission.rs", + "function": "zero", + "module": "commission" + }, + { + "file": "crypto.rs", + "function": "continuous_bar_labels", + "module": "crypto" + }, + { + "file": "crypto.rs", + "function": "funding_cumulative_pnl", + "module": "crypto" + }, + { + "file": "crypto.rs", + "function": "mark_session_boundaries", + "module": "crypto" + }, + { + "file": "currency.rs", + "function": "format", + "module": "currency" + }, + { + "file": "currency.rs", + "function": "from_code", + "module": "currency" + }, + { + "file": "cycle.rs", + "function": "compute_ht_core", + "module": "cycle" + }, + { + "file": "cycle.rs", + "function": "ht_dcperiod", + "module": "cycle" + }, + { + "file": "cycle.rs", + "function": "ht_dcphase", + "module": "cycle" + }, + { + "file": "cycle.rs", + "function": "ht_phasor", + "module": "cycle" + }, + { + "file": "cycle.rs", + "function": "ht_sine", + "module": "cycle" + }, + { + "file": "cycle.rs", + "function": "ht_trendline", + "module": "cycle" + }, + { + "file": "cycle.rs", + "function": "ht_trendmode", + "module": "cycle" + }, + { + "file": "extended.rs", + "function": "chandelier_exit", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "choppiness_index", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "donchian", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "hull_ma", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "ichimoku", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "keltner_channels", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "pivot_points", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "supertrend", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "vwap", + "module": "extended" + }, + { + "file": "extended.rs", + "function": "vwma", + "module": "extended" + }, + { + "file": "futures/basis.rs", + "function": "annualized_basis", + "module": "futures.basis" + }, + { + "file": "futures/basis.rs", + "function": "basis", + "module": "futures.basis" + }, + { + "file": "futures/basis.rs", + "function": "carry_spread", + "module": "futures.basis" + }, + { + "file": "futures/basis.rs", + "function": "implied_carry_rate", + "module": "futures.basis" + }, + { + "file": "futures/curve.rs", + "function": "calendar_spreads", + "module": "futures.curve" + }, + { + "file": "futures/curve.rs", + "function": "curve_slope", + "module": "futures.curve" + }, + { + "file": "futures/curve.rs", + "function": "curve_summary", + "module": "futures.curve" + }, + { + "file": "futures/roll.rs", + "function": "back_adjusted_continuous", + "module": "futures.roll" + }, + { + "file": "futures/roll.rs", + "function": "ratio_adjusted_continuous", + "module": "futures.roll" + }, + { + "file": "futures/roll.rs", + "function": "roll_yield", + "module": "futures.roll" + }, + { + "file": "futures/roll.rs", + "function": "weighted_continuous", + "module": "futures.roll" + }, + { + "file": "futures/synthetic.rs", + "function": "parity_gap", + "module": "futures.synthetic" + }, + { + "file": "futures/synthetic.rs", + "function": "synthetic_forward", + "module": "futures.synthetic" + }, + { + "file": "futures/synthetic.rs", + "function": "synthetic_spot", + "module": "futures.synthetic" + }, + { + "file": "math.rs", + "function": "max", + "module": "math" + }, + { + "file": "math.rs", + "function": "min", + "module": "math" + }, + { + "file": "math.rs", + "function": "sliding_max", + "module": "math" + }, + { + "file": "math.rs", + "function": "sliding_min", + "module": "math" + }, + { + "file": "math.rs", + "function": "sum", + "module": "math" + }, + { + "file": "math_ops.rs", + "function": "rolling_max", + "module": "math_ops" + }, + { + "file": "math_ops.rs", + "function": "rolling_maxindex", + "module": "math_ops" + }, + { + "file": "math_ops.rs", + "function": "rolling_min", + "module": "math_ops" + }, + { + "file": "math_ops.rs", + "function": "rolling_minindex", + "module": "math_ops" + }, + { + "file": "math_ops.rs", + "function": "rolling_sum", + "module": "math_ops" + }, + { + "file": "momentum.rs", + "function": "adx", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "adx_all", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "adxr", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "dx", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "minus_di", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "minus_dm", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "mom", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "plus_di", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "plus_dm", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "rsi", + "module": "momentum" + }, + { + "file": "momentum.rs", + "function": "stoch", + "module": "momentum" + }, + { + "file": "options/chain.rs", + "function": "atm_index", + "module": "options.chain" + }, + { + "file": "options/chain.rs", + "function": "label_moneyness", + "module": "options.chain" + }, + { + "file": "options/chain.rs", + "function": "select_strike_by_delta", + "module": "options.chain" + }, + { + "file": "options/chain.rs", + "function": "select_strike_by_offset", + "module": "options.chain" + }, + { + "file": "options/greeks.rs", + "function": "black_76_greeks", + "module": "options.greeks" + }, + { + "file": "options/greeks.rs", + "function": "black_scholes_greeks", + "module": "options.greeks" + }, + { + "file": "options/greeks.rs", + "function": "model_greeks", + "module": "options.greeks" + }, + { + "file": "options/greeks.rs", + "function": "model_theta", + "module": "options.greeks" + }, + { + "file": "options/iv.rs", + "function": "implied_volatility", + "module": "options.iv" + }, + { + "file": "options/iv.rs", + "function": "iv_percentile", + "module": "options.iv" + }, + { + "file": "options/iv.rs", + "function": "iv_rank", + "module": "options.iv" + }, + { + "file": "options/iv.rs", + "function": "iv_zscore", + "module": "options.iv" + }, + { + "file": "options/mod.rs", + "function": "sign", + "module": "options.mod" + }, + { + "file": "options/normal.rs", + "function": "cdf", + "module": "options.normal" + }, + { + "file": "options/normal.rs", + "function": "pdf", + "module": "options.normal" + }, + { + "file": "options/pricing.rs", + "function": "black_76_price", + "module": "options.pricing" + }, + { + "file": "options/pricing.rs", + "function": "black_scholes_price", + "module": "options.pricing" + }, + { + "file": "options/pricing.rs", + "function": "model_price", + "module": "options.pricing" + }, + { + "file": "options/pricing.rs", + "function": "price_lower_bound", + "module": "options.pricing" + }, + { + "file": "options/pricing.rs", + "function": "price_upper_bound", + "module": "options.pricing" + }, + { + "file": "options/surface.rs", + "function": "atm_iv", + "module": "options.surface" + }, + { + "file": "options/surface.rs", + "function": "linear_interpolate", + "module": "options.surface" + }, + { + "file": "options/surface.rs", + "function": "smile_metrics", + "module": "options.surface" + }, + { + "file": "options/surface.rs", + "function": "term_structure_slope", + "module": "options.surface" + }, + { + "file": "overlap.rs", + "function": "bbands", + "module": "overlap" + }, + { + "file": "overlap.rs", + "function": "ema", + "module": "overlap" + }, + { + "file": "overlap.rs", + "function": "macd", + "module": "overlap" + }, + { + "file": "overlap.rs", + "function": "sma", + "module": "overlap" + }, + { + "file": "overlap.rs", + "function": "sma_into", + "module": "overlap" + }, + { + "file": "overlap.rs", + "function": "wma", + "module": "overlap" + }, + { + "file": "pattern.rs", + "function": "body_size", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "candle_range", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdl2crows", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdl3blackcrows", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdl3inside", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdl3linestrike", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdl3outside", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdl3starsinsouth", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdl3whitesoldiers", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlabandonedbaby", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdladvanceblock", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlbelthold", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlbreakaway", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlclosingmarubozu", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlconcealbabyswall", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlcounterattack", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdldarkcloudcover", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdldoji", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdldojistar", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdldragonflydoji", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlengulfing", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdleveningdojistar", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdleveningstar", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlgapsidesidewhite", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlgravestonedoji", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlhammer", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlhangingman", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlharami", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlharamicross", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlhighwave", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlhikkake", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlhikkakemod", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlhomingpigeon", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlidentical3crows", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlinneck", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlinvertedhammer", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlkicking", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlkickingbylength", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlladderbottom", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdllongleggeddoji", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdllongline", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlmarubozu", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlmatchinglow", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlmathold", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlmorningdojistar", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlmorningstar", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlonneck", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlpiercing", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlrickshawman", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlrisefall3methods", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlseparatinglines", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlshootingstar", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlshortline", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlspinningtop", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlstalledpattern", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlsticksandwich", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdltakuri", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdltasukigap", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlthrusting", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdltristar", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlunique3river", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlupsidegap2crows", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "cdlxsidegap3methods", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "is_bearish", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "is_bullish", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "lower_shadow", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "upper_shadow", + "module": "pattern" + }, + { + "file": "pattern.rs", + "function": "validate_ohlc", + "module": "pattern" + }, + { + "file": "portfolio.rs", + "function": "beta_full", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "compose_weighted", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "correlation_matrix", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "drawdown_series", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "portfolio_volatility", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "ratio", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "relative_strength", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "rolling_beta", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "spread", + "module": "portfolio" + }, + { + "file": "portfolio.rs", + "function": "zscore_series", + "module": "portfolio" + }, + { + "file": "price_transform.rs", + "function": "avgprice", + "module": "price_transform" + }, + { + "file": "price_transform.rs", + "function": "medprice", + "module": "price_transform" + }, + { + "file": "price_transform.rs", + "function": "typprice", + "module": "price_transform" + }, + { + "file": "price_transform.rs", + "function": "wclprice", + "module": "price_transform" + }, + { + "file": "regime.rs", + "function": "detect_breaks_cusum", + "module": "regime" + }, + { + "file": "regime.rs", + "function": "regime_adx", + "module": "regime" + }, + { + "file": "regime.rs", + "function": "regime_combined", + "module": "regime" + }, + { + "file": "regime.rs", + "function": "rolling_variance_break", + "module": "regime" + }, + { + "file": "resampling.rs", + "function": "ohlcv_agg", + "module": "resampling" + }, + { + "file": "resampling.rs", + "function": "volume_bars", + "module": "resampling" + }, + { + "file": "signals.rs", + "function": "bottom_n_indices", + "module": "signals" + }, + { + "file": "signals.rs", + "function": "compose_rank", + "module": "signals" + }, + { + "file": "signals.rs", + "function": "rank_values", + "module": "signals" + }, + { + "file": "signals.rs", + "function": "top_n_indices", + "module": "signals" + }, + { + "file": "statistic.rs", + "function": "stddev", + "module": "statistic" + }, + { + "file": "streaming.rs", + "function": "fast_period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "new", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "reset", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "signal_period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "slow_period", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "streaming.rs", + "function": "update", + "module": "streaming" + }, + { + "file": "volatility.rs", + "function": "atr", + "module": "volatility" + }, + { + "file": "volatility.rs", + "function": "trange", + "module": "volatility" + }, + { + "file": "volume.rs", + "function": "ad", + "module": "volume" + }, + { + "file": "volume.rs", + "function": "adosc", + "module": "volume" + }, + { + "file": "volume.rs", + "function": "mfi", + "module": "volume" + }, + { + "file": "volume.rs", + "function": "obv", + "module": "volume" + } + ], + "public_function_count": 289 + }, + "wasm_node": { + "export_count": 95, + "exports": [ + "ad", + "adosc", + "adx", + "aggregate_tick_bars", + "aggregate_time_bars", + "aggregate_volume_bars_ticks", + "atr", + "avgprice", + "bbands", + "beta_full", + "bottom_n_indices", + "chandelier_exit", + "check_cross", + "check_threshold", + "choppiness_index", + "collect_alert_bars", + "continuous_bar_labels", + "detect_breaks_cusum", + "donchian", + "drawdown_series", + "ema", + "exchange_charges_rate", + "extract_trades", + "flat_per_order", + "forward_fill_nan", + "funding_cumulative_pnl", + "gst_rate", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendline", + "ht_trendmode", + "hull_ma", + "ichimoku", + "keltner_channels", + "lot_size", + "macd", + "make_chunk_ranges", + "max_brokerage", + "medprice", + "mfi", + "mom", + "monthly_contribution", + "new", + "obv", + "ohlcv_agg", + "per_lot", + "pivot_points", + "rank_series", + "rate_of_value", + "ratio", + "regime_adx", + "regime_combined", + "regulatory_charges_rate", + "relative_strength", + "rolling_beta", + "rolling_max", + "rolling_maxindex", + "rolling_min", + "rolling_minindex", + "rolling_sum", + "rolling_variance_break", + "rsi", + "set_exchange_charges_rate", + "set_flat_per_order", + "set_gst_rate", + "set_lot_size", + "set_max_brokerage", + "set_per_lot", + "set_rate_of_value", + "set_regulatory_charges_rate", + "set_stamp_duty_rate", + "set_stt_on_buy", + "set_stt_on_sell", + "set_stt_rate", + "signal_attribution", + "sma", + "spread", + "stamp_duty_rate", + "stochf", + "stt_on_buy", + "stt_on_sell", + "stt_rate", + "supertrend", + "top_n_indices", + "trade_stats", + "trim_overlap", + "typprice", + "volume_bars", + "vwap", + "vwma", + "wclprice", + "wma", + "zscore_series" + ] + } } } diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index 60ae67d..7b5a3d3 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -13,9 +13,92 @@ 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 --------------------------------- diff --git a/docs/changelog.rst b/docs/changelog.rst index 038578b..ea73df4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,7 +1,210 @@ Release Notes ============= -These docs track package version ``1.0.6``. +These docs track package version ``1.2.0``. + +1.2.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.2.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). 1.0.6 (2026-03-24) ------------------ diff --git a/docs/index.rst b/docs/index.rst index be643c5..d94da9a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,6 +59,7 @@ 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 `_ - MCP server for MCP-compatible clients — see `MCP guide `_ diff --git a/docs/performance.md b/docs/performance.md index 684296b..f23bc0d 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -234,6 +234,29 @@ 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 @@ -241,6 +264,7 @@ 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` @@ -301,13 +325,11 @@ for history and commits. Maintainer-facing list of slower paths and optional improvements. Update as bottlenecks are fixed or deferred. -**Backtest** (`python/ferro_ta/backtest.py`): -- Equity with commission uses an O(n) Python loop (lines 374–380). Could - vectorize (e.g. cumsum of commission events) or move to a small Rust helper. -- When both slippage and commission are used, `position_changed` is computed - twice; compute once and reuse. -- Built-in strategies do redundant `np.asarray(..., dtype=np.float64)` if - callers already pass contiguous float64; minor. +**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). **Batch** (`python/ferro_ta/batch.py`): - `batch_apply` runs a Python loop over columns (one Python call per column). diff --git a/docs/support_matrix.rst b/docs/support_matrix.rst index cb40972..82cc08c 100644 --- a/docs/support_matrix.rst +++ b/docs/support_matrix.rst @@ -59,10 +59,83 @@ Module status * - ``ferro_ta.analysis.*`` - Adjacent tooling - Useful analytics helpers, but not the primary product story. + * - ``ferro_ta.analysis.resample`` + - Supported (v1.2.0) + - ``resample_ohlcv()``, ``align_to_coarse()``, ``resample_ohlcv_labels()`` — pure-NumPy + OHLCV bar aggregation across timeframes. + * - ``ferro_ta.analysis.multitf`` + - Supported (v1.2.0) + - ``MultiTimeframeEngine`` — multi-timeframe signal generation with automatic alignment. + * - ``ferro_ta.analysis.adjust`` + - Supported (v1.2.0) + - ``adjust_ohlcv()``, ``adjust_for_splits()``, ``adjust_for_dividends()`` — backward-adjusted + price series for equity/index strategies. + * - ``ferro_ta.analysis.plot`` + - Supported (v1.2.0) + - ``plot_backtest()`` — interactive Plotly backtest visualization (requires plotly). + * - ``ferro_ta.analysis.regime`` + - Supported (v1.2.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.2.0) + - ``PortfolioOptimizer``, ``mean_variance_optimize()``, ``risk_parity_optimize()``, + ``max_sharpe_optimize()`` — portfolio optimization via SLSQP (requires scipy). + * - ``ferro_ta.analysis.live`` + - Supported (v1.2.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.2.0) + - New ``CommissionModel.spread_bps`` field; half-spread deducted per leg. + * - Short borrow cost (``short_borrow_rate_annual``) + - Supported (v1.2.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.2.0) + - ``BacktestEngine.with_breakeven_stop(pct)`` — moves stop to entry once profit reaches ``pct``. + * - Bracket order priority + - Supported (v1.2.0) + - When both SL and TP are breached on the same bar, the level closer to open fires first. + * - Leverage / margin modeling + - Supported (v1.2.0) + - ``BacktestEngine.with_leverage(margin_ratio, margin_call_pct)`` — tracks margin and + triggers force-close on margin call. + * - Loss circuit breakers + - Supported (v1.2.0) + - ``BacktestEngine.with_loss_limits(daily, total)`` — halts trading on drawdown breach. + * - Portfolio constraints + - Supported (v1.2.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 ------------------------- @@ -107,7 +180,7 @@ For source builds, packaging details, and platform notes, see Release status -------------- -These docs track package version ``1.0.6``. +These docs track package version ``1.2.0``. - Release notes by version: :doc:`changelog` - Canonical project changelog: `CHANGELOG.md `_ diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 6031956..5688b12 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -28,5 +28,54 @@ 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 diff --git a/fuzz/fuzz_targets/fuzz_atr.rs b/fuzz/fuzz_targets/fuzz_atr.rs new file mode 100644 index 0000000..37243ab --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_atr.rs @@ -0,0 +1,48 @@ +/*! +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 = (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"); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_bbands.rs b/fuzz/fuzz_targets/fuzz_bbands.rs new file mode 100644 index 0000000..e62b5dc --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_bbands.rs @@ -0,0 +1,60 @@ +/*! +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 = (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] + ); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_ema.rs b/fuzz/fuzz_targets/fuzz_ema.rs new file mode 100644 index 0000000..32153d1 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_ema.rs @@ -0,0 +1,35 @@ +/*! +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 = (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"); +}); diff --git a/fuzz/fuzz_targets/fuzz_macd.rs b/fuzz/fuzz_targets/fuzz_macd.rs new file mode 100644 index 0000000..ee4d0d0 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_macd.rs @@ -0,0 +1,41 @@ +/*! +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 = (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"); +}); diff --git a/fuzz/fuzz_targets/fuzz_mfi.rs b/fuzz/fuzz_targets/fuzz_mfi.rs new file mode 100644 index 0000000..1ad8cb8 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_mfi.rs @@ -0,0 +1,51 @@ +/*! +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 = (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]" + ); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_stoch.rs b/fuzz/fuzz_targets/fuzz_stoch.rs new file mode 100644 index 0000000..178669c --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_stoch.rs @@ -0,0 +1,63 @@ +/*! +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 = (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]" + ); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_wma.rs b/fuzz/fuzz_targets/fuzz_wma.rs new file mode 100644 index 0000000..32ce226 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wma.rs @@ -0,0 +1,35 @@ +/*! +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 = (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"); +}); diff --git a/pyproject.toml b/pyproject.toml index 832de16..b8ee5c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ferro-ta" -version = "1.0.6" +version = "1.2.0" description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" readme = "README.md" license = { text = "MIT" } @@ -43,6 +43,10 @@ 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 = [] @@ -50,6 +54,7 @@ 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", @@ -72,6 +77,19 @@ 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" diff --git a/python/ferro_ta/_utils.py b/python/ferro_ta/_utils.py index a981848..296bc95 100644 --- a/python/ferro_ta/_utils.py +++ b/python/ferro_ta/_utils.py @@ -74,7 +74,12 @@ 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: - raise ValueError("Input must be a 1-D array or list of prices.") + 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.", + ) return arr diff --git a/python/ferro_ta/analysis/__init__.py b/python/ferro_ta/analysis/__init__.py index ed507ea..5b52d30 100644 --- a/python/ferro_ta/analysis/__init__.py +++ b/python/ferro_ta/analysis/__init__.py @@ -1,6 +1,7 @@ """ ferro_ta.analysis — Portfolio analytics, strategy analysis, and financial modelling. + Sub-modules ----------- * :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics @@ -15,9 +16,47 @@ 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}") diff --git a/python/ferro_ta/analysis/adjust.py b/python/ferro_ta/analysis/adjust.py new file mode 100644 index 0000000..46f3afa --- /dev/null +++ b/python/ferro_ta/analysis/adjust.py @@ -0,0 +1,194 @@ +""" +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 diff --git a/python/ferro_ta/analysis/backtest.py b/python/ferro_ta/analysis/backtest.py index 168dc15..231206c 100644 --- a/python/ferro_ta/analysis/backtest.py +++ b/python/ferro_ta/analysis/backtest.py @@ -49,18 +49,74 @@ BacktestResult from __future__ import annotations +import dataclasses +import warnings +from collections import Counter from collections.abc import Callable -from typing import Optional, Union +from typing import Any, Optional, Union import numpy as np from numpy.typing import ArrayLike, NDArray +from ferro_ta._ferro_ta import CommissionModel +from ferro_ta._ferro_ta import Currency as _RustCurrency from ferro_ta._ferro_ta import backtest_core as _rust_backtest_core +from ferro_ta._ferro_ta import ( + backtest_multi_asset_core as _rust_backtest_multi_asset_core, +) +from ferro_ta._ferro_ta import backtest_ohlcv_core as _rust_backtest_ohlcv_core +from ferro_ta._ferro_ta import compute_performance_metrics as _rust_compute_perf_metrics +from ferro_ta._ferro_ta import drawdown_series as _rust_drawdown_series +from ferro_ta._ferro_ta import extract_trades_ohlcv as _rust_extract_trades +from ferro_ta._ferro_ta import kelly_fraction as _rust_kelly_fraction from ferro_ta._ferro_ta import macd_crossover_signals as _rust_macd_crossover_signals +from ferro_ta._ferro_ta import monte_carlo_bootstrap as _rust_monte_carlo_bootstrap from ferro_ta._ferro_ta import rsi_threshold_signals as _rust_rsi_threshold_signals from ferro_ta._ferro_ta import sma_crossover_signals as _rust_sma_crossover_signals +from ferro_ta._ferro_ta import walk_forward_indices as _rust_walk_forward_indices from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError +# --------------------------------------------------------------------------- +# Currency system (backed by Rust via ferro_ta._ferro_ta.Currency) +# --------------------------------------------------------------------------- + +# Re-export the Rust-backed Currency class as the public API. + +Currency = _RustCurrency + +# Built-in currency constants +INR: _RustCurrency = Currency.INR() +USD: _RustCurrency = Currency.USD() +EUR: _RustCurrency = Currency.EUR() +GBP: _RustCurrency = Currency.GBP() +JPY: _RustCurrency = Currency.JPY() +USDT: _RustCurrency = Currency.USDT() + +_CURRENCIES: dict[str, _RustCurrency] = { + "INR": INR, + "USD": USD, + "EUR": EUR, + "GBP": GBP, + "JPY": JPY, + "USDT": USDT, +} + + +def format_currency(amount: float, currency: _RustCurrency | None = None) -> str: + """Format *amount* using *currency*'s display style. + + Uses Indian lakh/crore grouping for INR, standard grouping for others. + + >>> format_currency(123456.78) + '₹1,23,456.78' + >>> format_currency(1234567.89, USD) + '$1,234,567.89' + """ + if currency is None: + currency = INR + return currency.format(amount) + + # --------------------------------------------------------------------------- # BacktestResult # --------------------------------------------------------------------------- @@ -246,6 +302,17 @@ def macd_crossover_strategy( # Built-in strategy registry # --------------------------------------------------------------------------- +_BENCHMARK_METRICS = frozenset( + ( + "alpha", + "beta", + "tracking_error", + "information_ratio", + "benchmark_cagr", + "benchmark_sharpe", + ) +) + _BUILTIN_STRATEGIES: dict[str, Callable[..., NDArray[np.float64]]] = { "rsi_30_70": rsi_strategy, "sma_crossover": sma_crossover_strategy, @@ -318,26 +385,15 @@ def backtest( raise FerroTAInputError(f"close must have at least 2 bars, got {len(c)}.") # ------------------------------------------------------------------ - # Resolve strategy - # ------------------------------------------------------------------ - if isinstance(strategy, str): - if strategy not in _BUILTIN_STRATEGIES: - raise FerroTAValueError( - f"Unknown strategy '{strategy}'. " - f"Available: {sorted(_BUILTIN_STRATEGIES)}" - ) - strategy_fn: Callable[..., NDArray[np.float64]] = _BUILTIN_STRATEGIES[strategy] - elif callable(strategy): - strategy_fn = strategy - else: - raise FerroTAValueError("strategy must be a string name or a callable.") - - # ------------------------------------------------------------------ - # Compute signals + # Resolve strategy & compute signals # ------------------------------------------------------------------ + strategy_fn = _resolve_strategy(strategy) signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64) positions, bar_returns, strategy_returns, equity = _rust_backtest_core( - c, signals, float(commission_per_trade), float(slippage_bps) + c, + signals, + commission_per_trade=float(commission_per_trade), + slippage_bps=float(slippage_bps), ) return BacktestResult( @@ -347,3 +403,1134 @@ def backtest( strategy_returns=np.asarray(strategy_returns, dtype=np.float64), equity=np.asarray(equity, dtype=np.float64), ) + + +# =========================================================================== +# Advanced API — AdvancedBacktestResult, BacktestEngine, walk_forward, monte_carlo +# =========================================================================== + + +class AdvancedBacktestResult(BacktestResult): + """Extended backtest result with full metrics, trade log, and drawdown series. + + All ``BacktestResult`` attributes are preserved (``isinstance`` checks work). + + Additional Attributes + --------------------- + metrics : dict[str, float] + Full performance metrics: cagr, sharpe, sortino, calmar, max_drawdown, + avg_drawdown, max_drawdown_duration_bars, ulcer_index, omega_ratio, + win_rate, profit_factor, r_expectancy, tail_ratio, skewness, kurtosis, etc. + trades : Any + Trade log as ``pd.DataFrame`` (if pandas is installed) with columns: + entry_bar, exit_bar, direction, entry_price, exit_price, pnl_pct, + duration_bars, mae, mfe. None if no trades were extracted. + drawdown_series : NDArray[np.float64] + Per-bar drawdown (always <= 0). + fill_prices : NDArray[np.float64] + Actual fill prices per bar (NaN when flat). NaN array in close-only mode. + """ + + __slots__ = BacktestResult.__slots__ + ( + "metrics", + "trades", + "drawdown_series", + "fill_prices", + "currency", + "initial_capital", + "equity_abs", + ) + + def __init__( + self, + signals: NDArray, + positions: NDArray, + bar_returns: NDArray, + strategy_returns: NDArray, + equity: NDArray, + metrics: dict, + trades: Any, + drawdown_series: NDArray, + fill_prices: NDArray, + currency: _RustCurrency = INR, + initial_capital: float = 100_000.0, + ) -> None: + super().__init__(signals, positions, bar_returns, strategy_returns, equity) + self.metrics = metrics + self.trades = trades + self.drawdown_series = drawdown_series + self.fill_prices = fill_prices + self.currency = currency + self.initial_capital = float(initial_capital) + self.equity_abs = equity * self.initial_capital + + def __repr__(self) -> str: # pragma: no cover + m = self.metrics + final = ( + float(self.equity_abs[-1]) + if len(self.equity_abs) > 0 + else self.initial_capital + ) + return ( + f"AdvancedBacktestResult(" + f"bars={len(self.signals)}, " + f"trades={self.n_trades}, " + f"sharpe={m.get('sharpe', float('nan')):.3f}, " + f"max_dd={m.get('max_drawdown', float('nan')):.1%}, " + f"final={self.currency.format(final)})" + ) + + def to_equity_dataframe(self, freq: str = "B") -> Any: + """Return equity and drawdown as a ``pd.DataFrame`` indexed by date. + + Parameters + ---------- + freq : str + pandas date-offset alias for the synthetic DatetimeIndex (default ``"B"``). + + Returns + ------- + pd.DataFrame with columns ``equity``, ``equity_abs``, ``strategy_returns``, + ``drawdown``, indexed by a synthetic ``pd.DatetimeIndex`` starting 2000-01-03. + Raises ``ImportError`` if pandas is not installed. + """ + try: + import pandas as pd + except ImportError as exc: + raise ImportError("pandas is required for to_equity_dataframe()") from exc + n = len(self.equity) + idx = pd.date_range("2000-01-03", periods=n, freq=freq) + return pd.DataFrame( + { + "equity": self.equity, + "equity_abs": self.equity_abs, + "strategy_returns": self.strategy_returns, + "drawdown": self.drawdown_series, + }, + index=idx, + ) + + def summary(self) -> dict: + """Return a concise performance summary dict. + + Includes the 9 most commonly cited metrics plus n_trades, + initial_capital, final_capital, absolute_pnl, and currency. + """ + m = self.metrics + keys = ( + "total_return", + "cagr", + "annualized_vol", + "sharpe", + "sortino", + "calmar", + "max_drawdown", + "win_rate", + "profit_factor", + ) + result = {k: m.get(k, float("nan")) for k in keys} + result["n_trades"] = self.n_trades + result["initial_capital"] = self.initial_capital + final_capital = ( + float(self.equity_abs[-1]) + if len(self.equity_abs) > 0 + else self.initial_capital + ) + result["final_capital"] = final_capital + result["absolute_pnl"] = final_capital - self.initial_capital + result["currency"] = self.currency.code + # Include benchmark metrics if available + for key in _BENCHMARK_METRICS: + if key in m: + result[key] = m[key] + return result + + +def _resolve_strategy( + strategy: Union[str, Callable], +) -> Callable[..., NDArray]: + if isinstance(strategy, str): + if strategy not in _BUILTIN_STRATEGIES: + raise FerroTAValueError( + f"Unknown strategy '{strategy}'. " + f"Available: {sorted(_BUILTIN_STRATEGIES)}" + ) + return _BUILTIN_STRATEGIES[strategy] + elif callable(strategy): + return strategy + raise FerroTAValueError("strategy must be a string name or a callable.") + + +def _pct_change(arr: NDArray) -> NDArray: + """Percentage change with zero-price guard. Returns array of length len(arr)-1.""" + return np.diff(arr) / np.where(arr[:-1] != 0, arr[:-1], 1.0) + + +def _kelly_stats(strategy_returns: NDArray) -> tuple[float, float, float]: + """Extract (win_rate, avg_win, avg_loss) from strategy returns. + + Returns (0, 0, 0) when there are no active trades. + """ + active = strategy_returns[np.isfinite(strategy_returns) & (strategy_returns != 0.0)] + if len(active) == 0: + return 0.0, 0.0, 0.0 + wins = active[active > 0.0] + losses = active[active < 0.0] + win_rate = len(wins) / len(active) + avg_win = float(wins.mean()) if len(wins) > 0 else 0.0 + avg_loss = float(np.abs(losses).mean()) if len(losses) > 0 else 0.0 + return win_rate, avg_win, avg_loss + + +def _build_trades_df( + positions: NDArray, + fill_prices: NDArray, + high: NDArray, + low: NDArray, + initial_capital: float = 100_000.0, +) -> Any: + """Extract trade log; returns pd.DataFrame if pandas available, else None.""" + try: + import pandas as pd + except ImportError: + return None + eb, xb, d, ep, xp, pnl, dur, mae, mfe = _rust_extract_trades( + positions, fill_prices, high, low + ) + if len(eb) == 0: + return pd.DataFrame( + columns=[ + "entry_bar", + "exit_bar", + "direction", + "entry_price", + "exit_price", + "pnl_pct", + "pnl_abs", + "duration_bars", + "mae", + "mfe", + ] + ) + df = pd.DataFrame( + { + "entry_bar": eb, + "exit_bar": xb, + "direction": d, + "entry_price": ep, + "exit_price": xp, + "pnl_pct": pnl, + "duration_bars": dur, + "mae": mae, + "mfe": mfe, + } + ) + df["pnl_abs"] = df["pnl_pct"] * initial_capital + return df + + +class BacktestEngine: + """Composable backtesting engine with a fluent builder interface. + + Example + ------- + >>> import numpy as np + >>> from ferro_ta.analysis.backtest import BacktestEngine + >>> close = np.cumprod(1 + np.random.randn(200) * 0.01) * 100 + >>> high = close * 1.01; low = close * 0.99; open_ = close * 0.999 + >>> result = ( + ... BacktestEngine() + ... .with_commission(0.001) + ... .with_slippage(5.0) + ... .with_ohlcv(high=high, low=low, open_=open_) + ... .with_stop_loss(0.03) + ... .run(close, strategy="rsi_30_70") + ... ) + >>> print(result.metrics["sharpe"]) + """ + + def __init__(self) -> None: + self._commission: float = 0.0 + self._commission_model: Optional[CommissionModel] = None + self._currency: _RustCurrency = INR + self._initial_capital: float = 100_000.0 + self._slippage_bps: float = 0.0 + self._slippage_pct_range: float = 0.0 + self._position_sizing: str = "fixed" + self._fixed_fraction: float = 1.0 + self._vol_window: int = 20 + self._target_vol: float = 0.10 + self._high: Optional[NDArray] = None + self._low: Optional[NDArray] = None + self._open: Optional[NDArray] = None + self._stop_loss_pct: float = 0.0 + self._take_profit_pct: float = 0.0 + self._trailing_stop_pct: float = 0.0 + self._fill_mode: str = "market_open" + self._periods_per_year: float = 252.0 + self._risk_free_rate: float = 0.0 + self._benchmark_close: Optional[NDArray] = None + self._limit_prices: Optional[NDArray] = None + self._max_hold_bars: int = 0 + self._breakeven_pct: float = 0.0 + # Phase 2: Portfolio & Risk + self._margin_ratio: float = 0.0 + self._margin_call_pct: float = 0.5 + self._daily_loss_limit: float = 0.0 + self._total_loss_limit: float = 0.0 + self._max_asset_weight: float = 1.0 + self._max_gross_exposure: float = 0.0 + self._max_net_exposure: float = 0.0 + + def with_commission(self, rate: float) -> BacktestEngine: + """Backward-compat: set a flat per-order fee (in base currency units).""" + self._commission = float(rate) + return self + + def with_commission_model(self, model: CommissionModel) -> BacktestEngine: + """Set a full commission+tax model (takes precedence over ``with_commission``).""" + self._commission_model = model + return self + + def with_currency( + self, currency: str | _RustCurrency | None = None + ) -> BacktestEngine: + """Set display currency (default: INR).""" + if currency is None: + currency = INR + if isinstance(currency, str): + try: + currency = Currency.from_code(currency) + except Exception: + raise FerroTAValueError( + f"Unknown currency code '{currency}'. " + f"Supported: {sorted(_CURRENCIES)}" + ) + self._currency = currency + return self + + def with_initial_capital(self, capital: float) -> BacktestEngine: + """Set starting capital in base currency (default: ₹1,00,000).""" + self._initial_capital = float(capital) + return self + + def with_benchmark(self, benchmark_close: ArrayLike) -> BacktestEngine: + """Set benchmark close prices for alpha/beta/tracking error computation.""" + self._benchmark_close = np.asarray(benchmark_close, dtype=np.float64) + return self + + def with_trailing_stop(self, pct: float) -> BacktestEngine: + """Set trailing stop distance as a fraction (e.g. 0.02 = 2%). 0 = disabled.""" + self._trailing_stop_pct = float(pct) + return self + + def with_slippage(self, bps: float) -> BacktestEngine: + self._slippage_bps = float(bps) + return self + + def with_ohlcv( + self, + *, + high: ArrayLike, + low: ArrayLike, + open_: ArrayLike, + ) -> BacktestEngine: + 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) -> BacktestEngine: + self._stop_loss_pct = float(pct) + return self + + def with_take_profit(self, pct: float) -> BacktestEngine: + self._take_profit_pct = float(pct) + return self + + def with_fill_mode(self, mode: str) -> BacktestEngine: + if mode not in ("market_open", "market_close"): + raise FerroTAValueError("fill_mode must be 'market_open' or 'market_close'") + self._fill_mode = mode + return self + + def with_position_sizing( + self, + method: str, + fraction: float = 1.0, + vol_window: int = 20, + target_vol: float = 0.10, + ) -> BacktestEngine: + valid = ( + "fixed", + "kelly", + "half_kelly", + "fixed_fractional", + "volatility_target", + ) + if method not in valid: + raise FerroTAValueError(f"position_sizing must be one of {valid}") + if method == "fixed_fractional" and not (0.0 < fraction <= 1.0): + raise FerroTAValueError("fixed_fractional fraction must be in (0, 1]") + self._vol_window = int(vol_window) + self._target_vol = float(target_vol) + self._position_sizing = method + self._fixed_fraction = float(fraction) + return self + + def with_calendar(self, periods_per_year: float) -> BacktestEngine: + self._periods_per_year = float(periods_per_year) + return self + + def with_risk_free_rate(self, rate: float) -> BacktestEngine: + self._risk_free_rate = float(rate) + return self + + def with_limit_orders(self, prices: ArrayLike) -> BacktestEngine: + """Set limit prices for entry/exit orders (requires OHLCV data via with_ohlcv). + + Parameters + ---------- + prices : array-like, shape (n_bars,) + Limit price for each signal bar. NaN (or 0) entries use market-order fill. + Buy limit: fill only when bar low <= limit_price (execute at limit_price). + Sell limit: fill only when bar high >= limit_price (execute at limit_price). + """ + self._limit_prices = np.asarray(prices, dtype=np.float64) + return self + + def with_max_hold(self, n_bars: int) -> BacktestEngine: + """Force exit after *n_bars* bars in trade regardless of signal (requires OHLCV). + + 0 = disabled (default). Useful for mean-reversion strategies. + """ + if int(n_bars) < 0: + raise FerroTAValueError("max_hold n_bars must be >= 0") + self._max_hold_bars = int(n_bars) + return self + + def with_slippage_pct_range(self, pct: float) -> BacktestEngine: + """Set slippage as a fraction of the bar's high-low range (requires OHLCV). + + Overrides ``with_slippage`` when both are set. Typical values: 0.05–0.20. + Example: pct=0.10 means slippage = 10% of bar's (high - low). + """ + self._slippage_pct_range = float(pct) + return self + + def with_breakeven_stop(self, pct: float) -> BacktestEngine: + """Move stop to entry price once profit reaches *pct* fraction (e.g. 0.02 = 2%). 0 = disabled.""" + self._breakeven_pct = float(pct) + return self + + def with_leverage( + self, margin_ratio: float, margin_call_pct: float = 0.5 + ) -> BacktestEngine: + """Enable margin/leverage modeling. margin_ratio=0.2 means 20% margin (5x leverage). + margin_call_pct=0.5 triggers a margin call when equity falls to 50% of initial margin.""" + self._margin_ratio = float(margin_ratio) + self._margin_call_pct = float(margin_call_pct) + return self + + def with_loss_limits( + self, daily: float = 0.0, total: float = 0.0 + ) -> BacktestEngine: + """Set circuit breakers. daily=0.02 halts after a 2% per-bar loss. total=0.20 halts after 20% drawdown.""" + self._daily_loss_limit = float(daily) + self._total_loss_limit = float(total) + return self + + def with_portfolio_constraints( + self, + max_asset_weight: float = 1.0, + max_gross_exposure: float = 0.0, + max_net_exposure: float = 0.0, + ) -> BacktestEngine: + """Set portfolio-level constraints for multi-asset backtests.""" + self._max_asset_weight = float(max_asset_weight) + self._max_gross_exposure = float(max_gross_exposure) + self._max_net_exposure = float(max_net_exposure) + return self + + def run( + self, + close: ArrayLike, + strategy: Union[str, Callable] = "rsi_30_70", + **strategy_kwargs: object, + ) -> AdvancedBacktestResult: + """Run the backtest and return an AdvancedBacktestResult.""" + c = np.asarray(close, dtype=np.float64) + if c.ndim != 1: + raise FerroTAInputError("close must be a 1-D array.") + if len(c) < 2: + raise FerroTAInputError(f"close must have at least 2 bars, got {len(c)}.") + + strategy_fn = _resolve_strategy(strategy) + signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64) + + cm = self._commission_model + commission_scalar = self._commission if cm is None else 0.0 + ic = self._initial_capital + + if self._position_sizing == "fixed_fractional": + signals = signals * self._fixed_fraction + + if self._position_sizing == "volatility_target": + proxy_rets = _pct_change(c) + w = self._vol_window + # Naive rolling window is O(n·w); cumsum-of-squares is O(n) with no per-bar allocation. + # Safe for financial returns (centred near zero → no catastrophic cancellation). + cs = np.cumsum(proxy_rets) + cs2 = np.cumsum(proxy_rets**2) + pad = np.zeros(1) + s1 = cs[w - 1 :] - np.concatenate([pad, cs[: len(cs) - w]]) + s2 = cs2[w - 1 :] - np.concatenate([pad, cs2[: len(cs2) - w]]) + var = np.maximum(s2 / w - (s1 / w) ** 2, 0.0) + rolling_vol = np.concatenate([np.full(w, np.nan), np.sqrt(var)]) * np.sqrt( + self._periods_per_year + ) + rolling_vol = np.concatenate([[np.nan], rolling_vol[: len(signals) - 1]]) + with np.errstate(divide="ignore", invalid="ignore"): + # NaN positions (warm-up) have rolling_vol<=0 → else-branch produces 1.0 + scale = np.where( + rolling_vol > 0, + np.clip(self._target_vol / rolling_vol, 0.0, 3.0), + 1.0, + ) + signals = signals * scale + + use_ohlcv = ( + self._high is not None and self._low is not None and self._open is not None + ) + + def _execute_run(sigs: NDArray) -> tuple: + if use_ohlcv: + pos, fp, br, sr, eq = _rust_backtest_ohlcv_core( + self._open, + self._high, + self._low, + c, + sigs, + self._fill_mode, + self._stop_loss_pct, + self._take_profit_pct, + self._trailing_stop_pct, + cm, + self._slippage_bps, + ic, + commission_scalar, + self._limit_prices, + self._max_hold_bars, + self._slippage_pct_range, + self._breakeven_pct, + self._periods_per_year, + self._margin_ratio, + self._margin_call_pct, + self._daily_loss_limit, + self._total_loss_limit, + ) + return ( + np.asarray(pos), + np.asarray(fp), + np.asarray(br), + np.asarray(sr), + np.asarray(eq), + ) + pos, br, sr, eq = _rust_backtest_core( + c, + sigs, + cm, + self._slippage_bps, + ic, + commission_scalar, + ) + return ( + np.asarray(pos), + np.full(len(c), np.nan, dtype=np.float64), + np.asarray(br), + np.asarray(sr), + np.asarray(eq), + ) + + bench_returns_arr = None + if self._benchmark_close is not None and len(self._benchmark_close) == len(c): + bc = self._benchmark_close + bench_returns_arr = np.concatenate([[0.0], _pct_change(bc)]) + + def _compute_metrics(sr: NDArray, eq: NDArray) -> dict: + return dict( + _rust_compute_perf_metrics( + sr, + eq, + self._periods_per_year, + self._risk_free_rate, + bench_returns_arr, + ) + ) + + # Kelly / half-Kelly: estimate fraction from a preliminary run, then re-run scaled + _kelly_kf: float = 0.0 + if self._position_sizing in ("kelly", "half_kelly"): + positions, fill_prices, bar_returns, strategy_returns, equity = ( + _execute_run(signals) + ) + wr, aw, al = _kelly_stats(strategy_returns) + if aw > 0.0: + try: + _kelly_kf = _rust_kelly_fraction(wr, aw, al) + fraction = ( + _kelly_kf + if self._position_sizing == "kelly" + else _kelly_kf / 2.0 + ) + signals = signals * fraction + except Exception as exc: + warnings.warn( + f"Kelly sizing failed, falling back to unit signals: {exc}", + stacklevel=2, + ) + + positions, fill_prices, bar_returns, strategy_returns, equity = _execute_run( + signals + ) + metrics = _compute_metrics(strategy_returns, equity) + + # Annotate Kelly info (reuse pre-computed fraction, avoid re-scanning returns) + if _kelly_kf > 0.0 and "kelly_fraction" not in metrics: + metrics["kelly_fraction"] = _kelly_kf + metrics["half_kelly_fraction"] = _kelly_kf / 2.0 + metrics["position_size_fraction"] = ( + _kelly_kf if self._position_sizing == "kelly" else _kelly_kf / 2.0 + ) + + high_arr: NDArray = self._high if use_ohlcv and self._high is not None else c + low_arr: NDArray = self._low if use_ohlcv and self._low is not None else c + trades = _build_trades_df(positions, fill_prices, high_arr, low_arr, ic) + + dd_arr, _ = _rust_drawdown_series(equity) + drawdown_series = np.asarray(dd_arr) + + return AdvancedBacktestResult( + signals=signals, + positions=positions, + bar_returns=bar_returns, + strategy_returns=strategy_returns, + equity=equity, + metrics=metrics, + trades=trades, + drawdown_series=drawdown_series, + fill_prices=fill_prices, + currency=self._currency, + initial_capital=ic, + ) + + +# --------------------------------------------------------------------------- +# Additional built-in strategies +# --------------------------------------------------------------------------- + + +def adx_trend_follow_strategy( + close: ArrayLike, + high: Optional[ArrayLike] = None, + low: Optional[ArrayLike] = None, + adx_period: int = 14, + adx_threshold: float = 25.0, + sma_period: int = 50, + **kwargs: object, +) -> NDArray: + """ADX trend-following: +1 when ADX>threshold AND close>SMA, else -1.""" + from ferro_ta._ferro_ta import adx as _adx + from ferro_ta._ferro_ta import sma as _sma + + c = np.asarray(close, dtype=np.float64) + h = np.asarray(high, dtype=np.float64) if high is not None else c * 1.001 + low_arr = np.asarray(low, dtype=np.float64) if low is not None else c * 0.999 + + adx_vals = np.asarray(_adx(h, low_arr, c, adx_period), dtype=np.float64) + sma_vals = np.asarray(_sma(c, sma_period), dtype=np.float64) + + out = np.where( + np.isnan(adx_vals) | np.isnan(sma_vals), + np.nan, + np.where((adx_vals > adx_threshold) & (c > sma_vals), 1.0, -1.0), + ) + return out + + +def bb_mean_revert_strategy( + close: ArrayLike, + timeperiod: int = 20, + nbdevup: float = 2.0, + nbdevdn: float = 2.0, + **kwargs: object, +) -> NDArray: + """Bollinger Band mean reversion: +1 near lower band, -1 near upper band.""" + from ferro_ta._ferro_ta import bbands as _bbands + + c = np.asarray(close, dtype=np.float64) + upper, middle, lower = _bbands(c, timeperiod, nbdevup, nbdevdn) + upper = np.asarray(upper, dtype=np.float64) + lower = np.asarray(lower, dtype=np.float64) + + out = np.where( + np.isnan(upper) | np.isnan(lower), + np.nan, + np.where(c <= lower, 1.0, np.where(c >= upper, -1.0, 0.0)), + ) + return out + + +def rsi_sma_combo_strategy( + close: ArrayLike, + rsi_period: int = 14, + oversold: float = 30.0, + overbought: float = 70.0, + sma_period: int = 50, + **kwargs: object, +) -> NDArray: + """RSI signal filtered by SMA trend: RSI oversold/overbought only in trend direction.""" + from ferro_ta._ferro_ta import sma as _sma + + c = np.asarray(close, dtype=np.float64) + rsi_signals = rsi_strategy(c, rsi_period, oversold, overbought) + sma_vals = np.asarray(_sma(c, sma_period), dtype=np.float64) + + trend = np.where(np.isnan(sma_vals), np.nan, np.where(c > sma_vals, 1.0, -1.0)) + # Only take RSI long signals in uptrend, RSI short signals in downtrend + out = np.where( + np.isnan(rsi_signals) | np.isnan(trend), + np.nan, + np.where( + (rsi_signals == 1.0) & (trend == 1.0), + 1.0, + np.where((rsi_signals == -1.0) & (trend == -1.0), -1.0, 0.0), + ), + ) + return out + + +# Register additional built-in strategies +_BUILTIN_STRATEGIES["adx_trend_follow"] = adx_trend_follow_strategy +_BUILTIN_STRATEGIES["bb_mean_revert"] = bb_mean_revert_strategy +_BUILTIN_STRATEGIES["rsi_sma_combo"] = rsi_sma_combo_strategy + + +# --------------------------------------------------------------------------- +# WalkForwardResult + walk_forward() +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class WalkForwardResult: + """Results from walk-forward analysis. + + Attributes + ---------- + fold_results : list[AdvancedBacktestResult] + Out-of-sample backtest result for each fold. + fold_indices : NDArray[np.int64] + Shape (n_folds, 4): [train_start, train_end, test_start, test_end]. + best_params_per_fold : list[dict] + Parameter dict that scored highest in each fold's training period. + oos_equity : NDArray[np.float64] + Concatenated out-of-sample equity curve (chained, not spliced raw). + oos_metrics : dict[str, float] + Performance metrics computed on the full OOS equity curve. + param_stability : dict[str, Any] + For each param name, the most-chosen value and its selection frequency. + """ + + fold_results: list + fold_indices: NDArray + best_params_per_fold: list + oos_equity: NDArray + oos_metrics: dict + param_stability: dict + + +def walk_forward( + close: ArrayLike, + strategy_fn: Callable, + param_grid: list, + train_bars: int, + test_bars: int, + *, + metric: str = "sharpe", + anchored: bool = False, + step_bars: int = 0, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + periods_per_year: float = 252.0, +) -> WalkForwardResult: + """Walk-forward analysis with grid search on each training fold. + + Parameters + ---------- + close : array-like + Full close price series. + strategy_fn : callable + Signal-generating function ``(close, **params) -> signals``. + param_grid : list[dict] + List of parameter dicts to test on the training set. + train_bars : int + Number of bars in each training window. + test_bars : int + Number of bars in each test (out-of-sample) window. + metric : str + Metric name from ``compute_performance_metrics`` to optimise (default "sharpe"). + anchored : bool + If True, training window always starts from bar 0 (expanding window). + step_bars : int + Step between folds. 0 → non-overlapping (step = test_bars). + commission_per_trade, slippage_bps : float + Applied in both training (for metric computation) and test. + periods_per_year : float + Annualisation factor for metrics (default 252). + + Returns + ------- + WalkForwardResult + """ + if metric in _BENCHMARK_METRICS: + raise FerroTAValueError( + f"metric '{metric}' requires a benchmark and is not supported in walk_forward(). " + f"Use a non-benchmark metric such as 'sharpe', 'cagr', or 'sortino'." + ) + + c = np.asarray(close, dtype=np.float64) + n = len(c) + + fold_idx = np.asarray( + _rust_walk_forward_indices(n, train_bars, test_bars, anchored, step_bars), + dtype=np.int64, + ) + + fold_results: list = [] + best_params_per_fold: list = [] + oos_returns_parts: list = [] + + engine_base = ( + BacktestEngine() + .with_commission(commission_per_trade) + .with_slippage(slippage_bps) + .with_calendar(periods_per_year) + ) + + for fold in fold_idx: + tr_start, tr_end, te_start, te_end = ( + int(fold[0]), + int(fold[1]), + int(fold[2]), + int(fold[3]), + ) + c_train = c[tr_start:tr_end] + c_test = c[te_start:te_end] + + # Grid search on training set + best_params: dict = param_grid[0] if param_grid else {} + best_score = float("-inf") + + for params in param_grid: + try: + signals_train = np.asarray( + strategy_fn(c_train, **params), dtype=np.float64 + ) + _, _, sr_train, eq_train = _rust_backtest_core( + c_train, + signals_train, + commission_per_trade=commission_per_trade, + slippage_bps=slippage_bps, + ) + sr_train = np.asarray(sr_train, dtype=np.float64) + eq_train = np.asarray(eq_train, dtype=np.float64) + fold_metrics = dict( + _rust_compute_perf_metrics( + sr_train, eq_train, periods_per_year, 0.0 + ) + ) + score = fold_metrics.get(metric, float("-inf")) + if score > best_score: + best_score = score + best_params = params + except Exception as exc: + warnings.warn( + f"walk_forward: training fold param evaluation failed: {exc}", + stacklevel=2, + ) + continue + + best_params_per_fold.append(best_params) + + # Test with best params + try: + test_result = engine_base.run(c_test, strategy_fn, **best_params) + except Exception as exc: + warnings.warn( + f"walk_forward: test fold failed, using flat equity: {exc}", + stacklevel=2, + ) + dummy = np.ones(len(c_test)) + test_result = AdvancedBacktestResult( + signals=dummy, + positions=dummy, + bar_returns=dummy, + strategy_returns=np.zeros(len(c_test)), + equity=dummy, + metrics={}, + trades=None, + drawdown_series=np.zeros(len(c_test)), + fill_prices=np.full(len(c_test), np.nan), + ) + + fold_results.append(test_result) + oos_returns_parts.append(test_result.strategy_returns) + + # Chain OOS equity curves from per-fold equity (preserves commission deductions) + if fold_results: + oos_equity_parts: list[NDArray] = [] + oos_returns = np.concatenate(oos_returns_parts) + cumulative = 1.0 + for fr in fold_results: + fold_eq = np.asarray(fr.equity, dtype=np.float64) + # Renormalize: fold equity starts at 1.0, scale to chain from prior fold + oos_equity_parts.append(fold_eq * cumulative) + cumulative *= float(fold_eq[-1]) if len(fold_eq) > 0 else 1.0 + oos_equity = np.concatenate(oos_equity_parts) + else: + oos_returns = np.array([0.0]) + oos_equity = np.array([1.0]) + + # OOS metrics on full concatenated curve + try: + oos_metrics = dict( + _rust_compute_perf_metrics(oos_returns, oos_equity, periods_per_year, 0.0) + ) + except Exception: + oos_metrics = {} + + # Parameter stability: how often each param value was chosen + param_stability: dict = {} + if best_params_per_fold: + all_keys = set().union(*[p.keys() for p in best_params_per_fold]) + for key in all_keys: + vals = [p.get(key) for p in best_params_per_fold if key in p] + counts = Counter(vals) + most_common_val, most_common_count = counts.most_common(1)[0] + param_stability[key] = { + "most_chosen": most_common_val, + "frequency": most_common_count / len(best_params_per_fold), + "counts": dict(counts), + } + + return WalkForwardResult( + fold_results=fold_results, + fold_indices=fold_idx, + best_params_per_fold=best_params_per_fold, + oos_equity=oos_equity, + oos_metrics=oos_metrics, + param_stability=param_stability, + ) + + +# --------------------------------------------------------------------------- +# MonteCarloResult + monte_carlo() +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class MonteCarloResult: + """Results from Monte Carlo bootstrap simulation. + + Attributes + ---------- + equity_curves : NDArray[np.float64] + Shape (n_sims, n_bars) — simulated equity curves. + terminal_equity : NDArray[np.float64] + Shape (n_sims,) — final equity value per simulation. + confidence_lower : NDArray[np.float64] + Lower confidence band per bar. + confidence_upper : NDArray[np.float64] + Upper confidence band per bar. + median_curve : NDArray[np.float64] + Median equity curve across simulations. + var : float + Value-at-Risk: worst ``(1-confidence)`` percentile of terminal equity. + cvar : float + Conditional VaR: mean of worst ``(1-confidence)`` fraction of terminal equity. + prob_profit : float + Fraction of simulations where terminal equity > 1.0. + n_sims : int + confidence : float + """ + + equity_curves: NDArray + terminal_equity: NDArray + confidence_lower: NDArray + confidence_upper: NDArray + median_curve: NDArray + var: float + cvar: float + prob_profit: float + n_sims: int + confidence: float + + +def monte_carlo( + result_or_returns: Union[BacktestResult, NDArray], + n_sims: int = 1000, + confidence: float = 0.95, + seed: int = 42, + block_size: int = 1, +) -> MonteCarloResult: + """Run Monte Carlo bootstrap simulation on strategy returns. + + Parameters + ---------- + result_or_returns : BacktestResult or array-like + Either a ``BacktestResult`` (uses its ``strategy_returns``) or + a 1-D array of returns directly. + n_sims : int + Number of bootstrap simulations (default 1000). + confidence : float + Confidence level for bands and VaR (default 0.95). + seed : int + Random seed for reproducibility. + block_size : int + Block size for stationary block bootstrap (1 = IID resample). + + Returns + ------- + MonteCarloResult + """ + if isinstance(result_or_returns, BacktestResult): + returns = np.asarray(result_or_returns.strategy_returns, dtype=np.float64) + else: + returns = np.asarray(result_or_returns, dtype=np.float64) + + equity_curves = np.asarray( + _rust_monte_carlo_bootstrap(returns, int(n_sims), int(seed), int(block_size)), + dtype=np.float64, + ) + + terminal_equity = equity_curves[:, -1] + + lower_pct = (1.0 - confidence) / 2.0 * 100.0 + upper_pct = (1.0 + confidence) / 2.0 * 100.0 + + pct_results = np.percentile(equity_curves, [lower_pct, upper_pct, 50.0], axis=0) + confidence_lower = pct_results[0] + confidence_upper = pct_results[1] + median_curve = pct_results[2] + + var_threshold = np.percentile(terminal_equity, (1.0 - confidence) * 100.0) + tail = terminal_equity[terminal_equity <= var_threshold] + cvar = float(np.mean(tail)) if len(tail) > 0 else float(var_threshold) + + prob_profit = float(np.mean(terminal_equity > 1.0)) + + return MonteCarloResult( + equity_curves=equity_curves, + terminal_equity=terminal_equity, + confidence_lower=confidence_lower, + confidence_upper=confidence_upper, + median_curve=median_curve, + var=float(var_threshold), + cvar=cvar, + prob_profit=prob_profit, + n_sims=int(n_sims), + confidence=float(confidence), + ) + + +# --------------------------------------------------------------------------- +# Portfolio backtest +# --------------------------------------------------------------------------- + + +def backtest_portfolio( + close_2d: ArrayLike, + weights_2d: ArrayLike, + *, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + periods_per_year: float = 252.0, + parallel: bool = True, + max_asset_weight: float = 1.0, + max_gross_exposure: float = 0.0, + max_net_exposure: float = 0.0, +) -> PortfolioBacktestResult: + """Backtest a portfolio of N assets in parallel. + + Parameters + ---------- + close_2d : array-like, shape (n_bars, n_assets) + Close prices for each asset. + weights_2d : array-like, shape (n_bars, n_assets) + Desired position per asset per bar (lagged internally like signals). + commission_per_trade : float + Per-position-change commission (default 0). + slippage_bps : float + Slippage in basis points (default 0). + periods_per_year : float + Annualisation factor for metrics (default 252). + parallel : bool + Use rayon parallelism (default True). + + Returns + ------- + PortfolioBacktestResult + """ + c2d = np.ascontiguousarray(close_2d, dtype=np.float64) + w2d = np.ascontiguousarray(weights_2d, dtype=np.float64) + + asset_returns, portfolio_returns, portfolio_equity = ( + _rust_backtest_multi_asset_core( + c2d, + w2d, + commission_per_trade, + slippage_bps, + parallel, + max_asset_weight, + max_gross_exposure, + max_net_exposure, + ) + ) + asset_returns = np.asarray(asset_returns, dtype=np.float64) + portfolio_returns = np.asarray(portfolio_returns, dtype=np.float64) + portfolio_equity = np.asarray(portfolio_equity, dtype=np.float64) + + metrics = dict( + _rust_compute_perf_metrics( + portfolio_returns, portfolio_equity, periods_per_year, 0.0 + ) + ) + + return PortfolioBacktestResult( + asset_returns=asset_returns, + portfolio_returns=portfolio_returns, + portfolio_equity=portfolio_equity, + metrics=metrics, + ) + + +@dataclasses.dataclass +class PortfolioBacktestResult: + """Result from a multi-asset portfolio backtest. + + Attributes + ---------- + asset_returns : NDArray[np.float64] + Shape (n_bars, n_assets) — per-asset strategy returns. + portfolio_returns : NDArray[np.float64] + Shape (n_bars,) — combined portfolio returns. + portfolio_equity : NDArray[np.float64] + Shape (n_bars,) — cumulative portfolio equity. + metrics : dict[str, float] + Full performance metrics on the portfolio equity curve. + """ + + asset_returns: NDArray + portfolio_returns: NDArray + portfolio_equity: NDArray + metrics: dict diff --git a/python/ferro_ta/analysis/live.py b/python/ferro_ta/analysis/live.py new file mode 100644 index 0000000..8ad9b58 --- /dev/null +++ b/python/ferro_ta/analysis/live.py @@ -0,0 +1,544 @@ +""" +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 diff --git a/python/ferro_ta/analysis/multitf.py b/python/ferro_ta/analysis/multitf.py new file mode 100644 index 0000000..2f2d46c --- /dev/null +++ b/python/ferro_ta/analysis/multitf.py @@ -0,0 +1,185 @@ +""" +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, + ) diff --git a/python/ferro_ta/analysis/optimize.py b/python/ferro_ta/analysis/optimize.py new file mode 100644 index 0000000..1981c1e --- /dev/null +++ b/python/ferro_ta/analysis/optimize.py @@ -0,0 +1,318 @@ +""" +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) diff --git a/python/ferro_ta/analysis/plot.py b/python/ferro_ta/analysis/plot.py new file mode 100644 index 0000000..43c2d36 --- /dev/null +++ b/python/ferro_ta/analysis/plot.py @@ -0,0 +1,277 @@ +""" +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}
Equity: %{y:.4f}", + ), + 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}
Benchmark: %{y:.4f}", + ), + 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}
Equity: %{y:.4f}", + ), + 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}
Equity: %{y:.4f}", + ), + 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}
Drawdown: %{y:.2%}", + ), + 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}
Position: %{y:.2f}", + ), + 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"{title}" + (f"
{subtitle}" 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 diff --git a/python/ferro_ta/analysis/regime.py b/python/ferro_ta/analysis/regime.py index 92df81a..b18b74e 100644 --- a/python/ferro_ta/analysis/regime.py +++ b/python/ferro_ta/analysis/regime.py @@ -277,6 +277,264 @@ 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", diff --git a/python/ferro_ta/analysis/resample.py b/python/ferro_ta/analysis/resample.py new file mode 100644 index 0000000..172c56f --- /dev/null +++ b/python/ferro_ta/analysis/resample.py @@ -0,0 +1,139 @@ +""" +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] diff --git a/python/ferro_ta/data/aggregation.py b/python/ferro_ta/data/aggregation.py index 0e4fdff..bfd5f08 100644 --- a/python/ferro_ta/data/aggregation.py +++ b/python/ferro_ta/data/aggregation.py @@ -39,6 +39,7 @@ 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", @@ -61,23 +62,25 @@ def _parse_rule(rule: str) -> tuple[str, float]: """ parts = rule.split(":", 1) if len(parts) != 2: - raise ValueError( + raise FerroTAValueError( f"Invalid rule format: {rule!r}. " "Expected 'time:', 'volume:', or 'tick:'." ) bar_type = parts[0].lower().strip() if bar_type not in ("time", "volume", "tick"): - raise ValueError( + raise FerroTAValueError( f"Unknown bar type {bar_type!r}. Supported types: 'time', 'volume', 'tick'." ) try: param = float(parts[1].strip()) except ValueError as exc: - raise ValueError( + raise FerroTAValueError( f"Cannot parse parameter {parts[1]!r} as a number in rule {rule!r}." ) from exc if param <= 0: - raise ValueError(f"Rule parameter must be > 0, got {param} in rule {rule!r}.") + raise FerroTAValueError( + f"Rule parameter must be > 0, got {param} in rule {rule!r}." + ) return bar_type, param @@ -171,7 +174,9 @@ def aggregate_ticks( extra = None else: # time if ts_arr is None: - raise ValueError("Time bars require a timestamp column in the tick data.") + raise FerroTAValueError( + "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) diff --git a/python/ferro_ta/indicators/extended.py b/python/ferro_ta/indicators/extended.py index 9b376ee..1c4274e 100644 --- a/python/ferro_ta/indicators/extended.py +++ b/python/ferro_ta/indicators/extended.py @@ -66,6 +66,7 @@ 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( @@ -103,14 +104,15 @@ 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) - return np.asarray(_rust_vwap(h, lo, c, v, timeperiod)) + try: + return np.asarray(_rust_vwap(h, lo, c, v, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) def SUPERTREND( @@ -164,7 +166,10 @@ def SUPERTREND( h = _to_f64(high) lo = _to_f64(low) c = _to_f64(close) - st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier) + try: + st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier) + except ValueError as e: + _normalize_rust_error(e) return np.asarray(st), np.asarray(d) @@ -205,9 +210,12 @@ def ICHIMOKU( h = _to_f64(high) lo = _to_f64(low) c = _to_f64(close) - t, k, sa, sb, ch = _rust_ichimoku( - h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement - ) + 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) return ( np.asarray(t), np.asarray(k), @@ -241,7 +249,10 @@ def DONCHIAN( """ h = _to_f64(high) lo = _to_f64(low) - upper, middle, lower = _rust_donchian(h, lo, timeperiod) + try: + upper, middle, lower = _rust_donchian(h, lo, timeperiod) + except ValueError as e: + _normalize_rust_error(e) return np.asarray(upper), np.asarray(middle), np.asarray(lower) @@ -279,13 +290,16 @@ def PIVOT_POINTS( """ valid_methods = {"classic", "fibonacci", "camarilla"} if method.lower() not in valid_methods: - raise ValueError( + raise FerroTAValueError( f"Unknown pivot method '{method}'. Use 'classic', 'fibonacci', or 'camarilla'." ) h = _to_f64(high) lo = _to_f64(low) c = _to_f64(close) - pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method) + try: + pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method) + except ValueError as e: + _normalize_rust_error(e) return ( np.asarray(pivot), np.asarray(r1), @@ -328,9 +342,12 @@ def KELTNER_CHANNELS( h = _to_f64(high) lo = _to_f64(low) c = _to_f64(close) - upper, middle, lower = _rust_keltner_channels( - h, lo, c, timeperiod, atr_period, multiplier - ) + try: + upper, middle, lower = _rust_keltner_channels( + h, lo, c, timeperiod, atr_period, multiplier + ) + except ValueError as e: + _normalize_rust_error(e) return np.asarray(upper), np.asarray(middle), np.asarray(lower) @@ -358,7 +375,10 @@ def HULL_MA( Implemented in Rust — all WMA computations are in-process. """ c = _to_f64(close) - return np.asarray(_rust_hull_ma(c, timeperiod)) + try: + return np.asarray(_rust_hull_ma(c, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) def CHANDELIER_EXIT( @@ -391,7 +411,10 @@ def CHANDELIER_EXIT( h = _to_f64(high) lo = _to_f64(low) c = _to_f64(close) - long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier) + try: + long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier) + except ValueError as e: + _normalize_rust_error(e) return np.asarray(long_exit), np.asarray(short_exit) @@ -419,7 +442,10 @@ def VWMA( """ c = _to_f64(close) v = _to_f64(volume) - return np.asarray(_rust_vwma(c, v, timeperiod)) + try: + return np.asarray(_rust_vwma(c, v, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) def CHOPPINESS_INDEX( @@ -452,7 +478,10 @@ def CHOPPINESS_INDEX( h = _to_f64(high) lo = _to_f64(low) c = _to_f64(close) - return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod)) + try: + return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) __all__ = [ diff --git a/scripts/bump_version.py b/scripts/bump_version.py index ba5011d..5505c23 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -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( diff --git a/src/aggregation/mod.rs b/src/aggregation/mod.rs index 23b0ceb..61938b0 100644 --- a/src/aggregation/mod.rs +++ b/src/aggregation/mod.rs @@ -1,18 +1,9 @@ -//! 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. +//! Tick/trade aggregation (thin PyO3 wrapper over ferro_ta_core::aggregation). 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>, Bound<'py, PyArray1>, @@ -21,7 +12,6 @@ type Ohlcv5<'py> = ( Bound<'py, PyArray1>, ); -/// Return type for time bars: five OHLCV arrays plus labels. type Ohlcv5AndLabels<'py> = ( Bound<'py, PyArray1>, Bound<'py, PyArray1>, @@ -31,21 +21,7 @@ type Ohlcv5AndLabels<'py> = ( Bound<'py, PyArray1>, ); -// --------------------------------------------------------------------------- -// 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>( @@ -65,58 +41,17 @@ pub fn aggregate_tick_bars<'py>( "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 = &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; - } - + let (ro, rh, rl, rc, rv) = ferro_ta_core::aggregation::aggregate_tick_bars(p, s, ticks_per_bar); Ok(( - 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), + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.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>( @@ -136,78 +71,17 @@ pub fn aggregate_volume_bars_ticks<'py>( "price and size must be non-empty and equal length", )); } - - let mut out_open: Vec = Vec::new(); - let mut out_high: Vec = Vec::new(); - let mut out_low: Vec = Vec::new(); - let mut out_close: Vec = Vec::new(); - let mut out_vol: Vec = 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); - } - + let (ro, rh, rl, rc, rv) = ferro_ta_core::aggregation::aggregate_volume_bars_ticks(p, s, volume_threshold); Ok(( - 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), + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.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>( @@ -225,63 +99,17 @@ pub fn aggregate_time_bars<'py>( "price, size, and labels must be non-empty and equal length", )); } - - let mut out_open: Vec = Vec::new(); - let mut out_high: Vec = Vec::new(); - let mut out_low: Vec = Vec::new(); - let mut out_close: Vec = Vec::new(); - let mut out_vol: Vec = Vec::new(); - let mut out_labels: Vec = 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); - + let (ro, rh, rl, rc, rv, rlbl) = ferro_ta_core::aggregation::aggregate_time_bars(p, s, lbl); Ok(( - 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), + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + rlbl.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)?)?; diff --git a/src/alerts/mod.rs b/src/alerts/mod.rs index ef9b745..cd40b52 100644 --- a/src/alerts/mod.rs +++ b/src/alerts/mod.rs @@ -1,40 +1,20 @@ -//! 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 +//! Alerts — condition evaluation helpers (thin PyO3 wrapper over ferro_ta_core::alerts). 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 — indicator values (e.g. RSI) +/// series : 1-D float64 array /// level : float — threshold value -/// 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). +/// direction : int — ``1`` (cross above) or ``-1`` (cross below) /// /// Returns /// ------- -/// 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). +/// 1-D int8 array — 1 at crossing bars, 0 elsewhere. #[pyfunction] pub fn check_threshold<'py>( py: Python<'py>, @@ -48,50 +28,15 @@ pub fn check_threshold<'py>( )); } let s = series.as_slice()?; - 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)) + let result = ferro_ta_core::alerts::check_threshold(s, level, direction); + Ok(result.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`` 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. +/// 1-D int8 array: ``1`` = bullish, ``-1`` = bearish, ``0`` = none. #[pyfunction] pub fn check_cross<'py>( py: Python<'py>, @@ -100,68 +45,26 @@ pub fn check_cross<'py>( ) -> PyResult>> { let f = fast.as_slice()?; let s = slow.as_slice()?; - let n = f.len(); - if n != s.len() { + if f.len() != s.len() { return Err(PyValueError::new_err( "fast and slow must have the same length", )); } - 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)) + let result = ferro_ta_core::alerts::check_cross(f, s); + Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// 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) +/// Collect bar indices where *mask* is non-zero. #[pyfunction] pub fn collect_alert_bars<'py>( py: Python<'py>, mask: PyReadonlyArray1<'py, i8>, ) -> PyResult>> { let m = mask.as_slice()?; - let indices: Vec = m - .iter() - .enumerate() - .filter(|(_, &v)| v != 0) - .map(|(i, _)| i as i64) - .collect(); - Ok(indices.into_pyarray(py)) + let result = ferro_ta_core::alerts::collect_alert_bars(m); + Ok(result.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)?)?; diff --git a/src/attribution/mod.rs b/src/attribution/mod.rs index 0faab89..c38167b 100644 --- a/src/attribution/mod.rs +++ b/src/attribution/mod.rs @@ -1,41 +1,12 @@ -//! 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. +//! Performance attribution (thin PyO3 wrapper over ferro_ta_core::attribution). use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use std::collections::HashMap; -// --------------------------------------------------------------------------- -// trade_stats -// --------------------------------------------------------------------------- +use crate::validation; /// 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>, @@ -47,68 +18,11 @@ pub fn trade_stats( if n == 0 { return Err(PyValueError::new_err("pnl must be non-empty")); } - if n != h.len() { - return Err(PyValueError::new_err( - "pnl and hold_bars must have the same length", - )); - } - - let mut wins: Vec = Vec::new(); - let mut losses: Vec = 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::() / wins.len() as f64 - }; - let avg_loss = if losses.is_empty() { - 0.0 - } else { - losses.iter().sum::() / 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::() / n as f64; - - Ok((win_rate, avg_win, avg_loss, profit_factor, avg_hold)) + validation::validate_equal_length(&[(n, "pnl"), (h.len(), "hold_bars")])?; + Ok(ferro_ta_core::attribution::trade_stats(p, h)) } -// --------------------------------------------------------------------------- -// 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>( @@ -119,48 +33,12 @@ pub fn monthly_contribution<'py>( let ret = bar_returns.as_slice()?; let mi = month_index.as_slice()?; let n = ret.len(); - 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 = 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 = map.keys().copied().collect(); - months.sort_unstable(); - let contributions: Vec = months.iter().map(|m| map[m]).collect(); - + validation::validate_equal_length(&[(n, "bar_returns"), (mi.len(), "month_index")])?; + let (months, contributions) = ferro_ta_core::attribution::monthly_contribution(ret, mi); 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>( @@ -171,33 +49,12 @@ pub fn signal_attribution<'py>( let ret = bar_returns.as_slice()?; let lbl = signal_labels.as_slice()?; let n = ret.len(); - if n != lbl.len() { - return Err(PyValueError::new_err( - "bar_returns and signal_labels must have the same length", - )); - } - - let mut map: HashMap = 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 = map.keys().copied().collect(); - labels.sort_unstable(); - let contributions: Vec = labels.iter().map(|l| map[l]).collect(); - + validation::validate_equal_length(&[(n, "bar_returns"), (lbl.len(), "signal_labels")])?; + let (labels, contributions) = ferro_ta_core::attribution::signal_attribution(ret, lbl); 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>( @@ -208,41 +65,11 @@ pub fn extract_trades<'py>( let pos = positions.as_slice()?; let ret = strategy_returns.as_slice()?; let n = pos.len(); - if n != ret.len() { - return Err(PyValueError::new_err( - "positions and strategy_returns must have the same length", - )); - } - - let mut pnl = Vec::::new(); - let mut hold = Vec::::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; - } - + validation::validate_equal_length(&[(n, "positions"), (ret.len(), "strategy_returns")])?; + let (pnl, hold) = ferro_ta_core::attribution::extract_trades(pos, ret); 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)?)?; diff --git a/src/backtest/commission.rs b/src/backtest/commission.rs new file mode 100644 index 0000000..0338a85 --- /dev/null +++ b/src/backtest/commission.rs @@ -0,0 +1,294 @@ +//! 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/backtest/currency.rs b/src/backtest/currency.rs new file mode 100644 index 0000000..f0a6b39 --- /dev/null +++ b/src/backtest/currency.rs @@ -0,0 +1,133 @@ +//! 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 { + 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() + } +} diff --git a/src/backtest/mod.rs b/src/backtest/mod.rs index 213b76b..e98118c 100644 --- a/src/backtest/mod.rs +++ b/src/backtest/mod.rs @@ -1,34 +1,126 @@ -//! Rust-backed strategy signal generation and backtest core. -//! -//! These functions move the hot loops from Python into Rust while preserving -//! the public Python behavior. +//! Thin PyO3 wrappers delegating to `ferro_ta_core::backtest`. -use crate::validation; -use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +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::*; -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 +use crate::validation; + +// --------------------------------------------------------------------------- +// 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, +} + +#[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, + ) -> 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, } - } else { - v } } // --------------------------------------------------------------------------- -// Strategy signal helpers +// Signal generators // --------------------------------------------------------------------------- -/// 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>( @@ -40,26 +132,10 @@ pub fn rsi_threshold_signals<'py>( ) -> PyResult>> { validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let prices = close.as_slice()?; - let rsi = ferro_ta_core::momentum::rsi(prices, timeperiod); - let out: Vec = 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(); + let out = core_bt::rsi_threshold_signals(prices, timeperiod, oversold, overbought); 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>( @@ -70,32 +146,12 @@ pub fn sma_crossover_signals<'py>( ) -> PyResult>> { 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 sma_fast = ferro_ta_core::overlap::sma(prices, fast); - let sma_slow = ferro_ta_core::overlap::sma(prices, slow); - let out: Vec = 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(); + let out = core_bt::sma_crossover_signals(prices, fast, slow) + .map_err(|e| PyValueError::new_err(e))?; 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>( @@ -108,137 +164,646 @@ 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 (macd_line, signal_line, _) = - ferro_ta_core::overlap::macd(prices, fastperiod, slowperiod, signalperiod); - let out: Vec = 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(); + let out = core_bt::macd_crossover_signals(prices, fastperiod, slowperiod, signalperiod) + .map_err(|e| PyValueError::new_err(e))?; Ok(out.into_pyarray(py)) } // --------------------------------------------------------------------------- -// Backtest core +// Backtest core (close-only) // --------------------------------------------------------------------------- -/// Backtest core loop over close prices and strategy signals. -/// -/// Returns `(positions, bar_returns, strategy_returns, equity)`. #[pyfunction] -#[pyo3(signature = (close, signals, commission_per_trade = 0.0, slippage_bps = 0.0))] +#[pyo3(signature = ( + close, signals, + commission = None, + slippage_bps = 0.0, + initial_capital = 100_000.0, + commission_per_trade = 0.0, +))] #[allow(clippy::type_complexity)] pub fn backtest_core<'py>( py: Python<'py>, close: PyReadonlyArray1<'py, f64>, signals: PyReadonlyArray1<'py, f64>, - commission_per_trade: f64, + commission: Option>, slippage_bps: f64, + initial_capital: f64, + commission_per_trade: f64, ) -> PyResult<( Bound<'py, PyArray1>, Bound<'py, PyArray1>, Bound<'py, PyArray1>, Bound<'py, PyArray1>, )> { + 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(|e| PyValueError::new_err(e))?; + + 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>, + slippage_bps: f64, + initial_capital: f64, + commission_per_trade: f64, + limit_prices: Option>, + 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>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + 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")])?; - 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]); - } + 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(|e| PyValueError::new_err(e))?; + + 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>, +) -> PyResult> { + 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(|e| PyValueError::new_err(e))?; + + 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)?; } - 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]; - } + Ok(dict) +} - let mut strategy_returns = vec![0.0_f64; n]; - for i in 0..n { - strategy_returns[i] = positions[i] * bar_returns[i]; - } +// --------------------------------------------------------------------------- +// Trade extraction +// --------------------------------------------------------------------------- - let mut position_changed = vec![false; n]; - for i in 1..n { - position_changed[i] = positions[i] != positions[i - 1]; - } +#[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>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + let pos = positions.as_slice()?; + let fp = fill_prices.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; - 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; - } - } - } + validation::validate_equal_length(&[ + (pos.len(), "positions"), + (fp.len(), "fill_prices"), + (h.len(), "high"), + (l.len(), "low"), + ])?; - 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 { - 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; - } + let trades = core_bt::extract_trades_ohlcv(pos, fp, h, l) + .map_err(|e| PyValueError::new_err(e))?; - 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); - } - } - } + let mut entry_bars: Vec = Vec::with_capacity(trades.len()); + let mut exit_bars: Vec = Vec::with_capacity(trades.len()); + let mut directions: Vec = Vec::with_capacity(trades.len()); + let mut entry_prices: Vec = Vec::with_capacity(trades.len()); + let mut exit_prices: Vec = Vec::with_capacity(trades.len()); + let mut pnl_pcts: Vec = Vec::with_capacity(trades.len()); + let mut duration_bars_vec: Vec = Vec::with_capacity(trades.len()); + let mut maes: Vec = Vec::with_capacity(trades.len()); + let mut mfes: Vec = 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(( - positions.into_pyarray(py), - bar_returns.into_pyarray(py), - strategy_returns.into_pyarray(py), - equity.into_pyarray(py), + 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>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + 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![vec![0.0; n_bars]; n_assets]; + let mut weights_cm: Vec> = 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]]; + } + } + + // For parallel execution, use rayon directly on the core's single_asset_backtest. + // Apply portfolio constraints first via the core function's logic. + + // Apply constraints + 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; + } + } + } + } + } + + // Run per-asset backtests (parallel or serial) + let asset_strategy_returns: Vec> = py.allow_threads(|| { + let run_asset = |j: usize| -> Vec { + 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() + } else { + (0..n_assets).map(run_asset).collect() + } + }); + + // Assemble asset_returns 2D array (n_bars, n_assets) + let mut asset_ret_arr = Array2::::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]; + } + } + + // 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), + )) +} + +// --------------------------------------------------------------------------- +// 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>> { + 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::::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>> { + let folds = core_bt::walk_forward_indices(n_bars, train_bars, test_bars, anchored, step_bars) + .map_err(|e| PyValueError::new_err(e))?; + + let n_folds = folds.len(); + let mut arr = Array2::::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 { + core_bt::kelly_fraction(win_rate, avg_win, avg_loss).map_err(|e| PyValueError::new_err(e)) +} + +#[pyfunction] +pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult { + core_bt::half_kelly_fraction(win_rate, avg_win, avg_loss).map_err(|e| PyValueError::new_err(e)) +} + +// --------------------------------------------------------------------------- +// 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> { + 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> { + 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::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/src/batch/mod.rs b/src/batch/mod.rs index db813a1..6916a2d 100644 --- a/src/batch/mod.rs +++ b/src/batch/mod.rs @@ -8,19 +8,57 @@ //! [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, ArrayView2}; +use ndarray::Array2; 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; -fn transpose_to_series_major(data: ArrayView2<'_, f64>) -> Array2 { - 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") +// --------------------------------------------------------------------------- +// numpy ↔ Vec> helpers +// --------------------------------------------------------------------------- + +/// Convert a numpy (n_samples, n_series) array into `Vec>` where +/// `result[j]` is column j (one time-series of length n_samples). +fn numpy2d_to_columns(arr: &ndarray::ArrayView2<'_, f64>) -> Vec> { + let (_n_samples, n_series) = arr.dim(); + (0..n_series) + .map(|j| arr.column(j).to_vec()) + .collect() +} + +/// Convert `Vec>` (columns) back into a numpy (n_samples, n_series) array. +fn columns_to_numpy2d<'py>( + py: Python<'py>, + n_samples: usize, + columns: Vec>, +) -> Bound<'py, PyArray2> { + let n_series = columns.len(); + let mut result = Array2::::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>, + cols_b: Vec>, +) -> (Bound<'py, PyArray2>, Bound<'py, PyArray2>) { + ( + columns_to_numpy2d(py, n_samples, cols_a), + columns_to_numpy2d(py, n_samples, cols_b), + ) } fn validate_same_shape( @@ -38,245 +76,41 @@ fn validate_same_shape( } } -fn finish_single_output<'py>( - py: Python<'py>, - n_samples: usize, - n_series: usize, - col_results: Vec>, -) -> Bound<'py, PyArray2> { - let mut result = Array2::::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) +fn map_core_err(err: String) -> PyErr { + PyValueError::new_err(err) } -fn finish_pair_output<'py>( - py: Python<'py>, - n_samples: usize, - n_series: usize, - col_results: Vec<(Vec, Vec)>, -) -> (Bound<'py, PyArray2>, Bound<'py, PyArray2>) { - let mut result_k = Array2::::from_elem((n_samples, n_series), f64::NAN); - let mut result_d = Array2::::from_elem((n_samples, n_series), f64::NAN); +// --------------------------------------------------------------------------- +// Parallel-aware unary batch helper +// --------------------------------------------------------------------------- - 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>( +/// 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>( py: Python<'py>, data: PyReadonlyArray2<'py, f64>, parallel: bool, - process_col: F, -) -> Bound<'py, PyArray2> + per_col: F, +) -> PyResult>> where F: Fn(&[f64]) -> Vec + Sync, { let arr = data.as_array(); - let (n_samples, n_series) = arr.dim(); - let series_major = transpose_to_series_major(arr); + let (n_samples, _n_series) = arr.dim(); + let columns = numpy2d_to_columns(&arr); let col_results: Vec> = 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 { - (0..n_series).into_par_iter().map(run).collect() + columns.par_iter().map(|col| per_col(col)).collect() } else { - (0..n_series).map(run).collect() + columns.iter().map(|col| per_col(col)).collect() } }); - finish_single_output(py, n_samples, n_series, col_results) + Ok(columns_to_numpy2d(py, n_samples, 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 { - let n = high.len(); - let typical_price: Vec = 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::() / timeperiod as f64; - let mad = window - .iter() - .map(|&value| (value - mean).abs()) - .sum::() - / 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> { - 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> { - 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> { - 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>>; - // --------------------------------------------------------------------------- // batch_sma // --------------------------------------------------------------------------- @@ -309,9 +143,9 @@ pub fn batch_sma<'py>( log::debug!( "batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" ); - Ok(run_unary_batch(py, data, parallel, |col| { + run_unary_batch_par(py, data, parallel, |col| { ferro_ta_core::overlap::sma(col, timeperiod) - })) + }) } // --------------------------------------------------------------------------- @@ -319,17 +153,6 @@ 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>( @@ -345,9 +168,9 @@ pub fn batch_ema<'py>( log::debug!( "batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" ); - Ok(run_unary_batch(py, data, parallel, |col| { + run_unary_batch_par(py, data, parallel, |col| { ferro_ta_core::overlap::ema(col, timeperiod) - })) + }) } // --------------------------------------------------------------------------- @@ -355,18 +178,6 @@ 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>( @@ -382,49 +193,9 @@ pub fn batch_rsi<'py>( log::debug!( "batch_rsi: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" ); - - 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 - })) + run_unary_batch_par(py, data, parallel, |col| { + ferro_ta_core::momentum::rsi(col, timeperiod) + }) } // --------------------------------------------------------------------------- @@ -451,40 +222,27 @@ 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 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 h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); let col_results: Vec> = py.allow_threads(|| { - let process_col = |series_idx: usize| -> Vec { - 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) + let process = |i: usize| { + ferro_ta_core::volatility::atr(&h_cols[i], &l_cols[i], &c_cols[i], timeperiod) }; if parallel { - (0..n_series).into_par_iter().map(process_col).collect() + (0..n_series).into_par_iter().map(process).collect() } else { - (0..n_series).map(process_col).collect() + (0..n_series).map(process).collect() } }); - Ok(finish_single_output(py, n_samples, n_series, col_results)) + Ok(columns_to_numpy2d(py, n_samples, col_results)) } // --------------------------------------------------------------------------- // batch_stoch // --------------------------------------------------------------------------- -/// Stoch batch result type (slowk, slowd arrays). type StochBatchResult<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray2>); #[pyfunction] @@ -507,40 +265,30 @@ 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 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 h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); let col_results: Vec<(Vec, Vec)> = py.allow_threads(|| { - let process_col = |series_idx: usize| -> (Vec, Vec) { - 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"); + let process = |i: usize| { ferro_ta_core::momentum::stoch( - high_col, - low_col, - close_col, + &h_cols[i], + &l_cols[i], + &c_cols[i], fastk_period, slowk_period, slowd_period, ) }; if parallel { - (0..n_series).into_par_iter().map(process_col).collect() + (0..n_series).into_par_iter().map(process).collect() } else { - (0..n_series).map(process_col).collect() + (0..n_series).map(process).collect() } }); - Ok(finish_pair_output(py, n_samples, n_series, col_results)) + + let (all_k, all_d): (Vec>, Vec>) = col_results.into_iter().unzip(); + Ok(column_pair_to_numpy2d(py, n_samples, all_k, all_d)) } // --------------------------------------------------------------------------- @@ -567,39 +315,29 @@ 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 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 h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); let col_results: Vec> = py.allow_threads(|| { - let process_col = |series_idx: usize| -> Vec { - 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) + let process = |i: usize| { + ferro_ta_core::momentum::adx(&h_cols[i], &l_cols[i], &c_cols[i], timeperiod) }; if parallel { - (0..n_series).into_par_iter().map(process_col).collect() + (0..n_series).into_par_iter().map(process).collect() } else { - (0..n_series).map(process_col).collect() + (0..n_series).map(process).collect() } }); - Ok(finish_single_output(py, n_samples, n_series, col_results)) + Ok(columns_to_numpy2d(py, n_samples, col_results)) } // --------------------------------------------------------------------------- // grouped 1-D execution // --------------------------------------------------------------------------- +type IndicatorArrayList = Vec>>; + #[pyfunction] #[pyo3(signature = (close, names, timeperiods, parallel = true))] pub fn run_close_indicators<'py>( @@ -609,22 +347,35 @@ pub fn run_close_indicators<'py>( timeperiods: Vec, parallel: bool, ) -> PyResult { - validate_indicator_requests(&names, &timeperiods)?; let close_values = close.as_slice()?; - let results: Vec>> = 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() + if parallel { + // Parallel path: call core per-indicator in parallel via Rayon + let results: Vec, 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()) + } } #[pyfunction] @@ -638,7 +389,6 @@ pub fn run_hlc_indicators<'py>( timeperiods: Vec, parallel: bool, ) -> PyResult { - validate_indicator_requests(&names, &timeperiods)?; let high_values = high.as_slice()?; let low_values = low.as_slice()?; let close_values = close.as_slice()?; @@ -649,27 +399,40 @@ pub fn run_hlc_indicators<'py>( )); } - let results: Vec>> = 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() + if parallel { + let results: Vec, 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()) + } } // --------------------------------------------------------------------------- diff --git a/src/chunked/mod.rs b/src/chunked/mod.rs index 25e7a1c..05e306e 100644 --- a/src/chunked/mod.rs +++ b/src/chunked/mod.rs @@ -1,45 +1,10 @@ -//! 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. +//! Chunked / out-of-core execution helpers (thin PyO3 wrapper over ferro_ta_core::chunked). 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>, @@ -47,67 +12,32 @@ pub fn trim_overlap<'py>( overlap: usize, ) -> PyResult>> { let s = chunk_out.as_slice()?; - let n = s.len(); - if overlap > n { + if overlap > s.len() { return Err(PyValueError::new_err(format!( - "overlap ({overlap}) must be <= chunk length ({n})" + "overlap ({overlap}) must be <= chunk length ({})", + s.len() ))); } - let out = s[overlap..].to_vec(); - Ok(out.into_pyarray(py)) + let result = ferro_ta_core::chunked::trim_overlap(s, overlap); + Ok(result.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>, ) -> PyResult>> { - let mut out: Vec = Vec::new(); - for chunk in &chunks { - out.extend_from_slice(chunk.as_slice()?); - } - Ok(out.into_pyarray(py)) + let vecs: Vec> = chunks + .iter() + .map(|c| c.as_slice().map(|s| s.to_vec())) + .collect::>()?; + 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)) } -// --------------------------------------------------------------------------- -// 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) +/// Compute (start, end) index pairs for chunked processing. #[pyfunction] pub fn make_chunk_ranges<'py>( py: Python<'py>, @@ -118,26 +48,12 @@ pub fn make_chunk_ranges<'py>( if chunk_size == 0 { return Err(PyValueError::new_err("chunk_size must be >= 1")); } - let mut ranges: Vec = 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)) + let result = ferro_ta_core::chunked::make_chunk_ranges(n, chunk_size, overlap); + Ok(result.into_pyarray(py)) } // --------------------------------------------------------------------------- -// chunk_apply_close_indicator +// chunk_apply_close_indicator — stays in PyO3 (dispatches to ferro_ta_core indicators) // --------------------------------------------------------------------------- fn compute_close_indicator( @@ -156,18 +72,6 @@ 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>( @@ -227,38 +131,17 @@ 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>> { let input = values.as_slice()?; - 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)) + let result = ferro_ta_core::chunked::forward_fill_nan(input); + Ok(result.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)?)?; diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index e1d157e..0adde51 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -1,40 +1,10 @@ -//! 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). +//! Crypto and 24/7 market helpers (thin PyO3 wrapper over ferro_ta_core::crypto). 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>, @@ -43,41 +13,16 @@ pub fn funding_cumulative_pnl<'py>( ) -> PyResult>> { let pos = position_size.as_slice()?; let rate = funding_rate.as_slice()?; - let n = pos.len(); - if n != rate.len() { + if pos.len() != rate.len() { return Err(PyValueError::new_err( "position_size and funding_rate must have the same length", )); } - 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)) + let result = ferro_ta_core::crypto::funding_cumulative_pnl(pos, rate); + Ok(result.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>, @@ -87,56 +32,21 @@ pub fn continuous_bar_labels<'py>( if period_bars == 0 { return Err(PyValueError::new_err("period_bars must be >= 1")); } - let out: Vec = (0..n_bars).map(|i| (i / period_bars) as i64).collect(); - Ok(out.into_pyarray(py)) + let result = ferro_ta_core::crypto::continuous_bar_labels(n_bars, period_bars); + Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// 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. +/// Return bar indices where a new UTC day begins. #[pyfunction] pub fn mark_session_boundaries<'py>( py: Python<'py>, timestamps_ns: PyReadonlyArray1<'py, i64>, ) -> PyResult>> { let ts = timestamps_ns.as_slice()?; - let n = ts.len(); - if n == 0 { - return Ok(Vec::::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)) + let result = ferro_ta_core::crypto::mark_session_boundaries(ts); + Ok(result.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)?)?; diff --git a/src/cycle/common.rs b/src/cycle/common.rs deleted file mode 100644 index 971d30f..0000000 --- a/src/cycle/common.rs +++ /dev/null @@ -1,187 +0,0 @@ -// Shared Hilbert Transform Core -// 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; - -pub(super) const HT_LOOKBACK: usize = 63; - -/// Shared output from the core Hilbert Transform computation. -pub(super) struct HtCore { - pub(super) trendline: Vec, - pub(super) dc_period: Vec, - pub(super) dc_phase: Vec, - pub(super) inphase: Vec, - pub(super) quadrature: Vec, - pub(super) trend_mode: Vec, -} - -/// Run the full Hilbert Transform pipeline on a slice of close prices. -pub(super) 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. - // Uses atan(Im/Re) per Ehlers' convention; guard against negative Re - // which would flip the sign of the period estimate. - 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, - } -} diff --git a/src/cycle/ht_dcperiod.rs b/src/cycle/ht_dcperiod.rs index b43d339..377685d 100644 --- a/src/cycle/ht_dcperiod.rs +++ b/src/cycle/ht_dcperiod.rs @@ -1,4 +1,3 @@ -use super::common::compute_ht_core; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; @@ -8,7 +7,6 @@ pub fn ht_dcperiod<'py>( py: Python<'py>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let prices = close.as_slice()?; - let core = compute_ht_core(prices); - Ok(core.dc_period.into_pyarray(py)) + let result = ferro_ta_core::cycle::ht_dcperiod(close.as_slice()?); + Ok(result.into_pyarray(py)) } diff --git a/src/cycle/ht_dcphase.rs b/src/cycle/ht_dcphase.rs index dce398c..fa11246 100644 --- a/src/cycle/ht_dcphase.rs +++ b/src/cycle/ht_dcphase.rs @@ -1,4 +1,3 @@ -use super::common::compute_ht_core; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; @@ -8,7 +7,6 @@ pub fn ht_dcphase<'py>( py: Python<'py>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let prices = close.as_slice()?; - let core = compute_ht_core(prices); - Ok(core.dc_phase.into_pyarray(py)) + let result = ferro_ta_core::cycle::ht_dcphase(close.as_slice()?); + Ok(result.into_pyarray(py)) } diff --git a/src/cycle/ht_phasor.rs b/src/cycle/ht_phasor.rs index 314b0f0..fea25e9 100644 --- a/src/cycle/ht_phasor.rs +++ b/src/cycle/ht_phasor.rs @@ -1,4 +1,3 @@ -use super::common::compute_ht_core; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; @@ -9,10 +8,6 @@ pub fn ht_phasor<'py>( py: Python<'py>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { - let prices = close.as_slice()?; - let core = compute_ht_core(prices); - Ok(( - core.inphase.into_pyarray(py), - core.quadrature.into_pyarray(py), - )) + let (inphase, quadrature) = ferro_ta_core::cycle::ht_phasor(close.as_slice()?); + Ok((inphase.into_pyarray(py), quadrature.into_pyarray(py))) } diff --git a/src/cycle/ht_sine.rs b/src/cycle/ht_sine.rs index 739faad..fa2756a 100644 --- a/src/cycle/ht_sine.rs +++ b/src/cycle/ht_sine.rs @@ -1,7 +1,5 @@ -use super::common::{compute_ht_core, HT_LOOKBACK}; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use std::f64::consts::PI; /// Hilbert Transform SineWave. Returns (sine, leadsine) where leadsine leads sine by 45°. #[pyfunction] @@ -10,20 +8,6 @@ pub fn ht_sine<'py>( py: Python<'py>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { - let prices = close.as_slice()?; - let n = prices.len(); - let core = compute_ht_core(prices); - - 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 - } - } - + let (sine, lead_sine) = ferro_ta_core::cycle::ht_sine(close.as_slice()?); Ok((sine.into_pyarray(py), lead_sine.into_pyarray(py))) } diff --git a/src/cycle/ht_trendline.rs b/src/cycle/ht_trendline.rs index f4190d6..a05d832 100644 --- a/src/cycle/ht_trendline.rs +++ b/src/cycle/ht_trendline.rs @@ -1,4 +1,3 @@ -use super::common::compute_ht_core; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; @@ -8,7 +7,6 @@ pub fn ht_trendline<'py>( py: Python<'py>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let prices = close.as_slice()?; - let core = compute_ht_core(prices); - Ok(core.trendline.into_pyarray(py)) + let result = ferro_ta_core::cycle::ht_trendline(close.as_slice()?); + Ok(result.into_pyarray(py)) } diff --git a/src/cycle/ht_trendmode.rs b/src/cycle/ht_trendmode.rs index da9337e..8163103 100644 --- a/src/cycle/ht_trendmode.rs +++ b/src/cycle/ht_trendmode.rs @@ -1,4 +1,3 @@ -use super::common::compute_ht_core; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; @@ -8,7 +7,6 @@ pub fn ht_trendmode<'py>( py: Python<'py>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let prices = close.as_slice()?; - let core = compute_ht_core(prices); - Ok(core.trend_mode.into_pyarray(py)) + let result = ferro_ta_core::cycle::ht_trendmode(close.as_slice()?); + Ok(result.into_pyarray(py)) } diff --git a/src/cycle/mod.rs b/src/cycle/mod.rs index 7a17845..4ba4845 100644 --- a/src/cycle/mod.rs +++ b/src/cycle/mod.rs @@ -3,8 +3,6 @@ //! //! All functions use a 63-bar lookback period (first 63 values are NaN). -mod common; - mod ht_dcperiod; mod ht_dcphase; mod ht_phasor; diff --git a/src/extended/mod.rs b/src/extended/mod.rs index 0e1c16c..376695d 100644 --- a/src/extended/mod.rs +++ b/src/extended/mod.rs @@ -1,76 +1,20 @@ -//! Extended Indicators — Rust implementations of indicators not in TA-Lib. +//! Extended Indicators — thin PyO3 wrappers delegating to `ferro_ta_core::extended`. //! -//! All compute-heavy work (sequential loops, rolling windows) is done in Rust. -//! Python wrappers in `python/ferro_ta/extended.py` are thin call-throughs that -//! handle input conversion and pandas/polars wrapping. +//! All compute-heavy work lives in the core crate. These functions convert +//! numpy arrays to slices, call the core, and convert the results back. #![allow(clippy::type_complexity)] #![allow(clippy::too_many_arguments)] -use std::collections::VecDeque; - use crate::validation; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/// Compute ATR array using Wilder smoothing (same as src/volatility/atr.rs). -fn compute_atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { - 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 -} - -/// Compute EMA using ferro_ta_core (SMA-seeded, matches Python EMA). -fn compute_ema_ta(prices: &[f64], timeperiod: usize) -> Vec { - ferro_ta_core::overlap::ema(prices, timeperiod) -} - -/// Compute WMA array using ferro_ta_core O(n) implementation. -fn compute_wma(prices: &[f64], timeperiod: usize) -> Vec { - ferro_ta_core::overlap::wma(prices, timeperiod) -} - // --------------------------------------------------------------------------- // VWAP // --------------------------------------------------------------------------- -/// Volume Weighted Average Price (cumulative or rolling). -/// -/// Parameters -/// ---------- -/// high, low, close, volume : 1-D float64 arrays (equal length) -/// timeperiod : 0 = cumulative from bar 0; >= 1 = rolling window -/// -/// Returns -/// ------- -/// 1-D float64 array of VWAP values. #[pyfunction] #[pyo3(signature = (high, low, close, volume, timeperiod = 0))] pub fn vwap<'py>( @@ -85,59 +29,13 @@ pub fn vwap<'py>( let lo = low.as_slice()?; let c = close.as_slice()?; let v = volume.as_slice()?; - let n = h.len(); validation::validate_equal_length(&[ - (n, "high"), + (h.len(), "high"), (lo.len(), "low"), (c.len(), "close"), (v.len(), "volume"), ])?; - - let mut result = vec![f64::NAN; n]; - let mut cum_tpv = 0.0f64; - let mut cum_vol = 0.0f64; - - if timeperiod == 0 { - for i in 0..n { - let tp = (h[i] + lo[i] + c[i]) / 3.0; - cum_tpv += tp * v[i]; - cum_vol += v[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.0f64; n]; - let mut cum_vol_arr = vec![0.0f64; n]; - for i in 0..n { - let tp = (h[i] + lo[i] + c[i]) / 3.0; - let tpv = tp * v[i]; - cum_tpv_arr[i] = tpv + if i > 0 { cum_tpv_arr[i - 1] } else { 0.0 }; - cum_vol_arr[i] = v[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 - }; - } - } + let result = ferro_ta_core::extended::vwap(h, lo, c, v, timeperiod); Ok(result.into_pyarray(py)) } @@ -145,9 +43,6 @@ pub fn vwap<'py>( // VWMA // --------------------------------------------------------------------------- -/// Volume Weighted Moving Average. -/// -/// VWMA = sum(close * volume, n) / sum(volume, n) #[pyfunction] #[pyo3(signature = (close, volume, timeperiod = 20))] pub fn vwma<'py>( @@ -159,32 +54,8 @@ pub fn vwma<'py>( validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let c = close.as_slice()?; let v = volume.as_slice()?; - let n = c.len(); - validation::validate_equal_length(&[(n, "close"), (v.len(), "volume")])?; - - let mut cum_cv = vec![0.0f64; n]; - let mut cum_v = vec![0.0f64; n]; - for i in 0..n { - cum_cv[i] = c[i] * v[i] + if i > 0 { cum_cv[i - 1] } else { 0.0 }; - cum_v[i] = v[i] + if i > 0 { cum_v[i - 1] } else { 0.0 }; - } - - let mut result = vec![f64::NAN; n]; - 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 }; - } + validation::validate_equal_length(&[(c.len(), "close"), (v.len(), "volume")])?; + let result = ferro_ta_core::extended::vwma(c, v, timeperiod); Ok(result.into_pyarray(py)) } @@ -192,10 +63,6 @@ pub fn vwma<'py>( // SUPERTREND // --------------------------------------------------------------------------- -/// ATR-based Supertrend indicator. -/// -/// Returns (supertrend_line, direction) where direction is an int8 array: -/// 1 = uptrend, -1 = downtrend, 0 = warmup. #[pyfunction] #[pyo3(signature = (high, low, close, timeperiod = 7, multiplier = 3.0))] pub fn supertrend<'py>( @@ -210,95 +77,15 @@ pub fn supertrend<'py>( let h = high.as_slice()?; let lo = low.as_slice()?; let c = close.as_slice()?; - let n = h.len(); - validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?; - - let atr = compute_atr(h, lo, c, timeperiod); - - let mut supertrend_out = vec![f64::NAN; n]; - let mut direction = vec![0i8; n]; - 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 Ok((supertrend_out.into_pyarray(py), direction.into_pyarray(py))); - } - - // Compute basic bands - let mut upper_basic = vec![f64::NAN; n]; - let mut lower_basic = vec![f64::NAN; n]; - for i in 0..n { - if !atr[i].is_nan() { - let hl2 = (h[i] + lo[i]) / 2.0; - upper_basic[i] = hl2 + multiplier * atr[i]; - lower_basic[i] = hl2 - multiplier * atr[i]; - } - } - - // Initialize band state at first valid ATR bar - upper_band[first_valid] = upper_basic[first_valid]; - lower_band[first_valid] = lower_basic[first_valid]; - // Keep supertrend_out NaN and direction 0 for indices 0..timeperiod (warmup) - - for i in (first_valid + 1)..n { - if atr[i].is_nan() { - continue; - } - - // Adjust lower band - lower_band[i] = if lower_basic[i] > lower_band[i - 1] || c[i - 1] < lower_band[i - 1] { - lower_basic[i] - } else { - lower_band[i - 1] - }; - - // Adjust upper band - upper_band[i] = if upper_basic[i] < upper_band[i - 1] || c[i - 1] > upper_band[i - 1] { - upper_basic[i] - } 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 { - // First output bar: bootstrap with -1 (downtrend) or 1 from price vs band - if c[i] > upper_band[i] { - 1 - } else { - -1 - } - } else if prev_dir == -1 { - if c[i] > upper_band[i] { - 1 - } else { - -1 - } - } else if c[i] < lower_band[i] { - -1 - } else { - 1 - }; - supertrend_out[i] = if direction[i] == 1 { - lower_band[i] - } else { - upper_band[i] - }; - } - } - - Ok((supertrend_out.into_pyarray(py), direction.into_pyarray(py))) + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let (st, dir) = ferro_ta_core::extended::supertrend(h, lo, c, timeperiod, multiplier); + Ok((st.into_pyarray(py), dir.into_pyarray(py))) } // --------------------------------------------------------------------------- // DONCHIAN // --------------------------------------------------------------------------- -/// Donchian Channels — rolling highest high / lowest low. -/// -/// Returns (upper, middle, lower) arrays. #[pyfunction] #[pyo3(signature = (high, low, timeperiod = 20))] pub fn donchian<'py>( @@ -314,51 +101,8 @@ pub fn donchian<'py>( validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let h = high.as_slice()?; let lo = low.as_slice()?; - let n = h.len(); - validation::validate_equal_length(&[(n, "high"), (lo.len(), "low")])?; - - let mut upper = vec![f64::NAN; n]; - let mut lower = vec![f64::NAN; n]; - let mut middle = vec![f64::NAN; n]; - - // Use monotonic deque for O(n) sliding max / min - let mut max_dq: VecDeque = VecDeque::new(); - let mut min_dq: VecDeque = VecDeque::new(); - - for i in 0..n { - // Remove out-of-window indices - while max_dq - .front() - .map(|&j| j + timeperiod <= i) - .unwrap_or(false) - { - max_dq.pop_front(); - } - while min_dq - .front() - .map(|&j| j + timeperiod <= i) - .unwrap_or(false) - { - min_dq.pop_front(); - } - // Maintain decreasing deque for max - while max_dq.back().map(|&j| h[j] <= h[i]).unwrap_or(false) { - max_dq.pop_back(); - } - max_dq.push_back(i); - // Maintain increasing deque for min - while min_dq.back().map(|&j| lo[j] >= lo[i]).unwrap_or(false) { - min_dq.pop_back(); - } - min_dq.push_back(i); - - if i + 1 >= timeperiod { - upper[i] = h[*max_dq.front().unwrap()]; - lower[i] = lo[*min_dq.front().unwrap()]; - middle[i] = (upper[i] + lower[i]) / 2.0; - } - } - + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low")])?; + let (upper, middle, lower) = ferro_ta_core::extended::donchian(h, lo, timeperiod); Ok(( upper.into_pyarray(py), middle.into_pyarray(py), @@ -370,10 +114,6 @@ pub fn donchian<'py>( // CHOPPINESS_INDEX // --------------------------------------------------------------------------- -/// Choppiness Index — measures market choppiness vs trending. -/// -/// Values near 100 → choppy; near 0 → trending. -/// Leading `timeperiod` values are NaN. #[pyfunction] #[pyo3(signature = (high, low, close, timeperiod = 14))] pub fn choppiness_index<'py>( @@ -387,73 +127,8 @@ pub fn choppiness_index<'py>( let h = high.as_slice()?; let lo = low.as_slice()?; let c = close.as_slice()?; - let n = h.len(); - validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?; - - // ATR(1) = True Range per bar - let mut tr = vec![0.0f64; n]; - tr[0] = h[0] - lo[0]; - for i in 1..n { - let hl = h[i] - lo[i]; - let hc = (h[i] - c[i - 1]).abs(); - let lc = (lo[i] - c[i - 1]).abs(); - tr[i] = hl.max(hc).max(lc); - } - - // Cumulative TR for rolling sum - let mut cum_tr = vec![0.0f64; 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 mut result = vec![f64::NAN; n]; - - // Rolling max and min using monotonic deques - let mut max_dq: VecDeque = VecDeque::new(); - let mut min_dq: VecDeque = VecDeque::new(); - - for i in 0..n { - while max_dq - .front() - .map(|&j| j + timeperiod <= i) - .unwrap_or(false) - { - max_dq.pop_front(); - } - while min_dq - .front() - .map(|&j| j + timeperiod <= i) - .unwrap_or(false) - { - min_dq.pop_front(); - } - while max_dq.back().map(|&j| h[j] <= h[i]).unwrap_or(false) { - max_dq.pop_back(); - } - max_dq.push_back(i); - while min_dq.back().map(|&j| lo[j] >= lo[i]).unwrap_or(false) { - min_dq.pop_back(); - } - min_dq.push_back(i); - - if i + 1 > timeperiod { - let prev_cum = if i >= timeperiod { - cum_tr[i - timeperiod] - } else { - 0.0 - }; - let sum_tr = cum_tr[i] - prev_cum; - let hh = h[*max_dq.front().unwrap()]; - let ll = lo[*min_dq.front().unwrap()]; - let hl_range = hh - ll; - if hl_range > 0.0 && log_n > 0.0 { - result[i] = 100.0 * (sum_tr / hl_range).log10() / log_n; - } - } - } - + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let result = ferro_ta_core::extended::choppiness_index(h, lo, c, timeperiod); Ok(result.into_pyarray(py)) } @@ -461,9 +136,6 @@ pub fn choppiness_index<'py>( // KELTNER_CHANNELS // --------------------------------------------------------------------------- -/// Keltner Channels — EMA ± (multiplier × ATR). -/// -/// Returns (upper, middle, lower) arrays. #[pyfunction] #[pyo3(signature = (high, low, close, timeperiod = 20, atr_period = 10, multiplier = 2.0))] pub fn keltner_channels<'py>( @@ -484,22 +156,9 @@ pub fn keltner_channels<'py>( let h = high.as_slice()?; let lo = low.as_slice()?; let c = close.as_slice()?; - let n = h.len(); - validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?; - - let middle = compute_ema_ta(c, timeperiod); - let atr = compute_atr(h, lo, c, 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; - } - } - + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let (upper, middle, lower) = + ferro_ta_core::extended::keltner_channels(h, lo, c, timeperiod, atr_period, multiplier); Ok(( upper.into_pyarray(py), middle.into_pyarray(py), @@ -511,9 +170,6 @@ pub fn keltner_channels<'py>( // HULL_MA // --------------------------------------------------------------------------- -/// Hull Moving Average (HMA). -/// -/// Formula: HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n)) #[pyfunction] #[pyo3(signature = (close, timeperiod = 16))] pub fn hull_ma<'py>( @@ -523,43 +179,14 @@ pub fn hull_ma<'py>( ) -> PyResult>> { validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let c = close.as_slice()?; - let n = c.len(); - - let half = (timeperiod / 2).max(1); - let sqrt_p = ((timeperiod as f64).sqrt().round() as usize).max(1); - - let wma_full = compute_wma(c, timeperiod); - let wma_half = compute_wma(c, 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 = compute_wma(raw_valid, sqrt_p); - for (k, &v) in hma_slice.iter().enumerate() { - hull[first_valid + k] = v; - } - } - - Ok(hull.into_pyarray(py)) + let result = ferro_ta_core::extended::hull_ma(c, timeperiod); + Ok(result.into_pyarray(py)) } // --------------------------------------------------------------------------- // CHANDELIER_EXIT // --------------------------------------------------------------------------- -/// Chandelier Exit — ATR-based trailing stop levels. -/// -/// Returns (long_exit, short_exit) arrays. #[pyfunction] #[pyo3(signature = (high, low, close, timeperiod = 22, multiplier = 3.0))] pub fn chandelier_exit<'py>( @@ -574,56 +201,9 @@ pub fn chandelier_exit<'py>( let h = high.as_slice()?; let lo = low.as_slice()?; let c = close.as_slice()?; - let n = h.len(); - validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?; - - let atr = compute_atr(h, lo, c, timeperiod); - - // Rolling max/min using monotonic deques - let mut max_dq: VecDeque = VecDeque::new(); - let mut min_dq: VecDeque = VecDeque::new(); - let mut highest_high = vec![f64::NAN; n]; - let mut lowest_low = vec![f64::NAN; n]; - - for i in 0..n { - while max_dq - .front() - .map(|&j| j + timeperiod <= i) - .unwrap_or(false) - { - max_dq.pop_front(); - } - while min_dq - .front() - .map(|&j| j + timeperiod <= i) - .unwrap_or(false) - { - min_dq.pop_front(); - } - while max_dq.back().map(|&j| h[j] <= h[i]).unwrap_or(false) { - max_dq.pop_back(); - } - max_dq.push_back(i); - while min_dq.back().map(|&j| lo[j] >= lo[i]).unwrap_or(false) { - min_dq.pop_back(); - } - min_dq.push_back(i); - - if i + 1 >= timeperiod { - highest_high[i] = h[*max_dq.front().unwrap()]; - lowest_low[i] = lo[*min_dq.front().unwrap()]; - } - } - - 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]; - } - } - + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let (long_exit, short_exit) = + ferro_ta_core::extended::chandelier_exit(h, lo, c, timeperiod, multiplier); Ok((long_exit.into_pyarray(py), short_exit.into_pyarray(py))) } @@ -631,9 +211,6 @@ pub fn chandelier_exit<'py>( // ICHIMOKU // --------------------------------------------------------------------------- -/// Ichimoku Cloud (Ichimoku Kinko Hyo). -/// -/// Returns (tenkan, kijun, senkou_a, senkou_b, chikou) arrays. #[pyfunction] #[pyo3(signature = (high, low, close, tenkan_period = 9, kijun_period = 26, senkou_b_period = 52, displacement = 26))] pub fn ichimoku<'py>( @@ -658,62 +235,16 @@ pub fn ichimoku<'py>( let h = high.as_slice()?; let lo = low.as_slice()?; let c = close.as_slice()?; - let n = h.len(); - validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?; - - // Helper: rolling (H+L)/2 using monotonic deques - let midpoint_rolling = |period: usize| -> Vec { - let mut result = vec![f64::NAN; n]; - let mut max_dq: VecDeque = VecDeque::new(); - let mut min_dq: VecDeque = VecDeque::new(); - for i in 0..n { - while max_dq.front().map(|&j| j + period <= i).unwrap_or(false) { - max_dq.pop_front(); - } - while min_dq.front().map(|&j| j + period <= i).unwrap_or(false) { - min_dq.pop_front(); - } - while max_dq.back().map(|&j| h[j] <= h[i]).unwrap_or(false) { - max_dq.pop_back(); - } - max_dq.push_back(i); - while min_dq.back().map(|&j| lo[j] >= lo[i]).unwrap_or(false) { - min_dq.pop_back(); - } - min_dq.push_back(i); - if i + 1 >= period { - result[i] = (h[*max_dq.front().unwrap()] + lo[*min_dq.front().unwrap()]) / 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(&c[..n - displacement]); - } - + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let (tenkan, kijun, senkou_a, senkou_b, chikou) = ferro_ta_core::extended::ichimoku( + h, + lo, + c, + tenkan_period, + kijun_period, + senkou_b_period, + displacement, + ); Ok(( tenkan.into_pyarray(py), kijun.into_pyarray(py), @@ -727,10 +258,6 @@ pub fn ichimoku<'py>( // PIVOT_POINTS // --------------------------------------------------------------------------- -/// Pivot Points — support / resistance levels computed from previous bar. -/// -/// method: "classic" | "fibonacci" | "camarilla" -/// Returns (pivot, r1, s1, r2, s2) arrays. #[pyfunction] #[pyo3(signature = (high, low, close, method = "classic"))] pub fn pivot_points<'py>( @@ -749,51 +276,17 @@ pub fn pivot_points<'py>( let h = high.as_slice()?; let lo = low.as_slice()?; let c = close.as_slice()?; - let n = h.len(); - validation::validate_equal_length(&[(n, "high"), (lo.len(), "low"), (c.len(), "close")])?; - - 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]; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; let method_lower = method.to_lowercase(); - for i in 1..n { - let ph = h[i - 1]; - let pl = lo[i - 1]; - let pc = c[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; - } - _ => { - return Err(PyValueError::new_err(format!( - "Unknown pivot method '{}'. Use 'classic', 'fibonacci', or 'camarilla'.", - method - ))); - } - } + if !matches!(method_lower.as_str(), "classic" | "fibonacci" | "camarilla") { + return Err(PyValueError::new_err(format!( + "Unknown pivot method '{}'. Use 'classic', 'fibonacci', or 'camarilla'.", + method + ))); } + let (pivot, r1, s1, r2, s2) = ferro_ta_core::extended::pivot_points(h, lo, c, method); Ok(( pivot.into_pyarray(py), r1.into_pyarray(py), diff --git a/src/math_ops/mod.rs b/src/math_ops/mod.rs index 76f60e5..6c70f7c 100644 --- a/src/math_ops/mod.rs +++ b/src/math_ops/mod.rs @@ -1,26 +1,10 @@ -//! Rust rolling math operators — O(n) sliding window using monotonic deques. -//! -//! Functions exposed to Python: -//! rolling_sum — Rolling sum over `timeperiod` bars -//! rolling_max — Rolling maximum (O(n) via monotonic deque) -//! rolling_min — Rolling minimum (O(n) via monotonic deque) -//! rolling_maxindex — Index of rolling maximum -//! rolling_minindex — Index of rolling minimum - -use std::collections::VecDeque; +//! Rolling math operators (thin PyO3 wrapper over ferro_ta_core::math_ops). use crate::validation; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -// --------------------------------------------------------------------------- -// rolling_sum -// --------------------------------------------------------------------------- - /// Rolling sum over `timeperiod` bars. -/// -/// Uses a prefix-sum array for O(n) computation. -/// Leading `timeperiod - 1` values are NaN. #[pyfunction] #[pyo3(signature = (real, timeperiod = 30))] pub fn rolling_sum<'py>( @@ -30,29 +14,11 @@ pub fn rolling_sum<'py>( ) -> PyResult>> { validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let prices = real.as_slice()?; - let n = prices.len(); - let mut result = vec![f64::NAN; n]; - if n < timeperiod { - return Ok(result.into_pyarray(py)); - } - // Prefix sum - let mut cs = vec![0.0f64; n + 1]; - for i in 0..n { - cs[i + 1] = cs[i] + prices[i]; - } - for i in (timeperiod - 1)..n { - result[i] = cs[i + 1] - cs[i + 1 - timeperiod]; - } + let result = ferro_ta_core::math_ops::rolling_sum(prices, timeperiod); Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// rolling_max -// --------------------------------------------------------------------------- - /// Rolling maximum over `timeperiod` bars (O(n) monotonic deque). -/// -/// Leading `timeperiod - 1` values are NaN. #[pyfunction] #[pyo3(signature = (real, timeperiod = 30))] pub fn rolling_max<'py>( @@ -62,34 +28,11 @@ pub fn rolling_max<'py>( ) -> PyResult>> { validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let prices = real.as_slice()?; - let n = prices.len(); - let mut result = vec![f64::NAN; n]; - let mut dq: VecDeque = VecDeque::new(); - - for i in 0..n { - // Remove indices out of the window - while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) { - dq.pop_front(); - } - // Maintain decreasing deque - while dq.back().map(|&j| prices[j] <= prices[i]).unwrap_or(false) { - dq.pop_back(); - } - dq.push_back(i); - if i + 1 >= timeperiod { - result[i] = prices[*dq.front().unwrap()]; - } - } + let result = ferro_ta_core::math_ops::rolling_max(prices, timeperiod); Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// rolling_min -// --------------------------------------------------------------------------- - /// Rolling minimum over `timeperiod` bars (O(n) monotonic deque). -/// -/// Leading `timeperiod - 1` values are NaN. #[pyfunction] #[pyo3(signature = (real, timeperiod = 30))] pub fn rolling_min<'py>( @@ -99,34 +42,11 @@ pub fn rolling_min<'py>( ) -> PyResult>> { validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let prices = real.as_slice()?; - let n = prices.len(); - let mut result = vec![f64::NAN; n]; - let mut dq: VecDeque = VecDeque::new(); - - for i in 0..n { - while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) { - dq.pop_front(); - } - // Maintain increasing deque - while dq.back().map(|&j| prices[j] >= prices[i]).unwrap_or(false) { - dq.pop_back(); - } - dq.push_back(i); - if i + 1 >= timeperiod { - result[i] = prices[*dq.front().unwrap()]; - } - } + let result = ferro_ta_core::math_ops::rolling_min(prices, timeperiod); Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// rolling_maxindex -// --------------------------------------------------------------------------- - -/// Index of rolling maximum over `timeperiod` bars (O(n) monotonic deque). -/// -/// Returns the 0-based index into the input array. During the warmup window -/// the value is `-1` (not valid — mask with warmup period if needed). +/// Index of rolling maximum over `timeperiod` bars. #[pyfunction] #[pyo3(signature = (real, timeperiod = 30))] pub fn rolling_maxindex<'py>( @@ -136,33 +56,11 @@ pub fn rolling_maxindex<'py>( ) -> PyResult>> { validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let prices = real.as_slice()?; - let n = prices.len(); - let mut result = vec![-1i64; n]; - let mut dq: VecDeque = 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| prices[j] <= prices[i]).unwrap_or(false) { - dq.pop_back(); - } - dq.push_back(i); - if i + 1 >= timeperiod { - result[i] = *dq.front().unwrap() as i64; - } - } + let result = ferro_ta_core::math_ops::rolling_maxindex(prices, timeperiod); Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// rolling_minindex -// --------------------------------------------------------------------------- - -/// Index of rolling minimum over `timeperiod` bars (O(n) monotonic deque). -/// -/// Returns the 0-based index into the input array. During the warmup window -/// the value is `-1` (not valid — mask with warmup period if needed). +/// Index of rolling minimum over `timeperiod` bars. #[pyfunction] #[pyo3(signature = (real, timeperiod = 30))] pub fn rolling_minindex<'py>( @@ -172,29 +70,10 @@ pub fn rolling_minindex<'py>( ) -> PyResult>> { validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; let prices = real.as_slice()?; - let n = prices.len(); - let mut result = vec![-1i64; n]; - let mut dq: VecDeque = 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| prices[j] >= prices[i]).unwrap_or(false) { - dq.pop_back(); - } - dq.push_back(i); - if i + 1 >= timeperiod { - result[i] = *dq.front().unwrap() as i64; - } - } + let result = ferro_ta_core::math_ops::rolling_minindex(prices, timeperiod); Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// register -// --------------------------------------------------------------------------- - pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(pyo3::wrap_pyfunction!(rolling_sum, m)?)?; m.add_function(pyo3::wrap_pyfunction!(rolling_max, m)?)?; diff --git a/src/momentum/adx.rs b/src/momentum/adx.rs index f41197f..0921699 100644 --- a/src/momentum/adx.rs +++ b/src/momentum/adx.rs @@ -5,6 +5,16 @@ use crate::validation; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; +/// Six-tuple of bound PyArray1 vectors (PLUS_DM, MINUS_DM, +DI, -DI, DX, ADX). +type AdxAllResult<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + /// Plus Directional Movement (Wilder smoothing). #[pyfunction] #[pyo3(signature = (high, low, timeperiod = 14))] @@ -158,3 +168,38 @@ pub fn adxr<'py>( let result = ferro_ta_core::momentum::adxr(highs, lows, closes, timeperiod); Ok(result.into_pyarray(py)) } + +/// Compute all six ADX-family outputs in a single TR/PDM/MDM pass. +/// +/// Returns (plus_dm, minus_dm, plus_di, minus_di, dx, adx) — six arrays of +/// the same length as the inputs. Use this when you need more than one ADX +/// family output to avoid redundant computation. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn adx_all<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + validation::validate_equal_length(&[ + (highs.len(), "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let (pdm, mdm, pdi, mdi, dx, adx) = + ferro_ta_core::momentum::adx_all(highs, lows, closes, timeperiod); + Ok(( + pdm.into_pyarray(py), + mdm.into_pyarray(py), + pdi.into_pyarray(py), + mdi.into_pyarray(py), + dx.into_pyarray(py), + adx.into_pyarray(py), + )) +} diff --git a/src/momentum/mod.rs b/src/momentum/mod.rs index 3700151..7babb82 100644 --- a/src/momentum/mod.rs +++ b/src/momentum/mod.rs @@ -47,6 +47,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(pyo3::wrap_pyfunction!(self::adx::dx, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::adx::adx, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::adx::adxr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::adx_all, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::trix::trix, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::ultosc::ultosc, m)?)?; Ok(()) diff --git a/src/pattern/cdl2crows.rs b/src/pattern/cdl2crows.rs index 9bc3ed5..1df0ddb 100644 --- a/src/pattern/cdl2crows.rs +++ b/src/pattern/cdl2crows.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdl2crows<'py>( py: Python<'py>, @@ -11,33 +9,10 @@ pub fn cdl2crows<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, _h1, _l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o3, _h3, _l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - // Two Crows: - // 1. First candle is a long white (bullish) candle - // 2. Second candle gaps up (opens above first close) and closes lower but still above first close - // 3. Third candle opens within second body and closes within first body - if is_bullish(o1, c1) - && is_bearish(o2, c2) - && o2 > c1 // gap up - && c2 > c1 // second still closes above first close - && is_bearish(o3, c3) - && o3 < o2 && o3 > c2 // opens within second body - && c3 > o1 && c3 < c1 - // closes within first body - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl2crows(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdl3blackcrows.rs b/src/pattern/cdl3blackcrows.rs index 617d5ab..ea305e7 100644 --- a/src/pattern/cdl3blackcrows.rs +++ b/src/pattern/cdl3blackcrows.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdl3blackcrows<'py>( py: Python<'py>, @@ -11,57 +9,10 @@ pub fn cdl3blackcrows<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, h2, l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let body3 = body_size(o3, c3); - let range1 = candle_range(h1, l1); - let range2 = candle_range(h2, l2); - let range3 = candle_range(h3, l3); - - // All three candles must be bearish with large bodies - let long_body1 = range1 > 0.0 && body1 >= range1 * 0.6; - let long_body2 = range2 > 0.0 && body2 >= range2 * 0.5; - let long_body3 = range3 > 0.0 && body3 >= range3 * 0.5; - - // Each opens within the previous candle's body and closes lower - let open2_in_body1 = o2 < o1 && o2 > c1; - let open3_in_body2 = o3 < o2 && o3 > c2; - - // Small upper shadows (closes near the low) - let small_upper1 = upper_shadow(o1, h1, c1) <= body1 * 0.3; - let small_upper2 = upper_shadow(o2, h2, c2) <= body2 * 0.3; - let small_upper3 = upper_shadow(o3, h3, c3) <= body3 * 0.3; - - if is_bearish(o1, c1) - && is_bearish(o2, c2) - && is_bearish(o3, c3) - && long_body1 - && long_body2 - && long_body3 - && open2_in_body1 - && open3_in_body2 - && small_upper1 - && small_upper2 - && small_upper3 - && c2 < c1 - && c3 < c2 - && l3 < l2 - && l2 < l1 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3blackcrows(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdl3inside.rs b/src/pattern/cdl3inside.rs index 43af0cd..f7c33a6 100644 --- a/src/pattern/cdl3inside.rs +++ b/src/pattern/cdl3inside.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdl3inside<'py>( py: Python<'py>, @@ -11,39 +9,10 @@ pub fn cdl3inside<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (_o3, _h3, _l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let range1 = candle_range(h1, l1); - - let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; - - // Candle 2 body is inside candle 1 body (harami condition) - let body2_high = o2.max(c2); - let body2_low = o2.min(c2); - let body1_high = o1.max(c1); - let body1_low = o1.min(c1); - let inside = body2_high <= body1_high && body2_low >= body1_low && body2 < body1 * 0.5; - - // Three Inside Up: C1 bearish, C2 bullish harami, C3 closes above C2 close - if is_bearish(o1, c1) && large_body1 && inside && is_bullish(o2, c2) && c3 > c2 { - result[i] = 100; - } - // Three Inside Down: C1 bullish, C2 bearish harami, C3 closes below C2 close - else if is_bullish(o1, c1) && large_body1 && inside && is_bearish(o2, c2) && c3 < c2 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3inside(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdl3linestrike.rs b/src/pattern/cdl3linestrike.rs index c5a3655..7c599bf 100644 --- a/src/pattern/cdl3linestrike.rs +++ b/src/pattern/cdl3linestrike.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdl3linestrike<'py>( py: Python<'py>, @@ -11,39 +9,10 @@ pub fn cdl3linestrike<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 3..n { - let (o0, c0) = (opens[i - 3], closes[i - 3]); - let (o1, c1) = (opens[i - 2], closes[i - 2]); - let (o2, c2) = (opens[i - 1], closes[i - 1]); - let (o3, c3) = (opens[i], closes[i]); - if is_bearish(o0, c0) - && is_bearish(o1, c1) - && is_bearish(o2, c2) - && c1 < c0 - && c2 < c1 - && is_bullish(o3, c3) - && o3 < c2 - && c3 > o0 - { - result[i] = 100; - } else if is_bullish(o0, c0) - && is_bullish(o1, c1) - && is_bullish(o2, c2) - && c1 > c0 - && c2 > c1 - && is_bearish(o3, c3) - && o3 > c2 - && c3 < o0 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3linestrike(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdl3outside.rs b/src/pattern/cdl3outside.rs index 6497320..39a8461 100644 --- a/src/pattern/cdl3outside.rs +++ b/src/pattern/cdl3outside.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdl3outside<'py>( py: Python<'py>, @@ -11,34 +9,10 @@ pub fn cdl3outside<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, _h1, _l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (_o3, _h3, _l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - let body1_high = o1.max(c1); - let body1_low = o1.min(c1); - let body2_high = o2.max(c2); - let body2_low = o2.min(c2); - - // Engulfing: candle 2 body completely covers candle 1 body - let engulfs = body2_high > body1_high && body2_low < body1_low; - - // Three Outside Up: C1 bearish, C2 bullish engulfing, C3 closes above C2 - if is_bearish(o1, c1) && is_bullish(o2, c2) && engulfs && c3 > c2 { - result[i] = 100; - } - // Three Outside Down: C1 bullish, C2 bearish engulfing, C3 closes below C2 - else if is_bullish(o1, c1) && is_bearish(o2, c2) && engulfs && c3 < c2 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3outside(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdl3starsinsouth.rs b/src/pattern/cdl3starsinsouth.rs index e86abde..9ff6016 100644 --- a/src/pattern/cdl3starsinsouth.rs +++ b/src/pattern/cdl3starsinsouth.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdl3starsinsouth<'py>( py: Python<'py>, @@ -11,29 +9,10 @@ pub fn cdl3starsinsouth<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - if is_bearish(o0, c0) - && is_bearish(o1, c1) - && is_bearish(o2, c2) - && h1 <= h0 - && l1 >= l0 - && h2 <= h1 - && l2 >= l1 - && body_size(o2, c2) <= body_size(o1, c1) * 0.6 - && upper_shadow(o2, h2, c2) <= body_size(o2, c2) * 0.2 - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3starsinsouth(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdl3whitesoldiers.rs b/src/pattern/cdl3whitesoldiers.rs index 31d25e8..69771c7 100644 --- a/src/pattern/cdl3whitesoldiers.rs +++ b/src/pattern/cdl3whitesoldiers.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdl3whitesoldiers<'py>( py: Python<'py>, @@ -11,54 +9,10 @@ pub fn cdl3whitesoldiers<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, h2, l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let body3 = body_size(o3, c3); - let range1 = candle_range(h1, l1); - let range2 = candle_range(h2, l2); - let range3 = candle_range(h3, l3); - - let long_body1 = range1 > 0.0 && body1 >= range1 * 0.6; - let long_body2 = range2 > 0.0 && body2 >= range2 * 0.5; - let long_body3 = range3 > 0.0 && body3 >= range3 * 0.5; - - let open2_in_body1 = o2 > o1 && o2 < c1; - let open3_in_body2 = o3 > o2 && o3 < c2; - - let small_lower1 = lower_shadow(o1, l1, c1) <= body1 * 0.3; - let small_lower2 = lower_shadow(o2, l2, c2) <= body2 * 0.3; - let small_lower3 = lower_shadow(o3, l3, c3) <= body3 * 0.3; - - if is_bullish(o1, c1) - && is_bullish(o2, c2) - && is_bullish(o3, c3) - && long_body1 - && long_body2 - && long_body3 - && open2_in_body1 - && open3_in_body2 - && small_lower1 - && small_lower2 - && small_lower3 - && c2 > c1 - && c3 > c2 - && h3 > h2 - && h2 > h1 - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3whitesoldiers(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlabandonedbaby.rs b/src/pattern/cdlabandonedbaby.rs index 87c5a63..c6f2dac 100644 --- a/src/pattern/cdlabandonedbaby.rs +++ b/src/pattern/cdlabandonedbaby.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlabandonedbaby<'py>( py: Python<'py>, @@ -11,47 +9,10 @@ pub fn cdlabandonedbaby<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let range1 = candle_range(h1, l1); - let range2 = candle_range(h2, l2); - let body0 = body_size(o0, c0); - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let is_doji1 = range1 > 0.0 && body1 / range1 <= 0.1; - if is_bearish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.5 - && is_doji1 - && h1 < l0 - && is_bullish(o2, c2) - && range2 > 0.0 - && body2 >= range2 * 0.5 - && l2 > h1 - { - result[i] = 100; - } else if is_bullish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.5 - && is_doji1 - && l1 > h0 - && is_bearish(o2, c2) - && range2 > 0.0 - && body2 >= range2 * 0.5 - && h2 < l1 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlabandonedbaby(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdladvanceblock.rs b/src/pattern/cdladvanceblock.rs index ee2453d..692b12b 100644 --- a/src/pattern/cdladvanceblock.rs +++ b/src/pattern/cdladvanceblock.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdladvanceblock<'py>( py: Python<'py>, @@ -11,36 +9,10 @@ pub fn cdladvanceblock<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, _l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let body0 = body_size(o0, c0); - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let us0 = upper_shadow(o0, h0, c0); - let us1 = upper_shadow(o1, h1, c1); - let us2 = upper_shadow(o2, h2, c2); - if is_bullish(o0, c0) - && is_bullish(o1, c1) - && is_bullish(o2, c2) - && c1 > c0 - && c2 > c1 - && o1 >= o0 - && o1 <= c0 - && o2 >= o1 - && o2 <= c1 - && (body1 < body0 || body2 < body1 || us2 > us1 || us1 > us0) - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdladvanceblock(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlbelthold.rs b/src/pattern/cdlbelthold.rs index 99c56dd..3b819b1 100644 --- a/src/pattern/cdlbelthold.rs +++ b/src/pattern/cdlbelthold.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlbelthold<'py>( py: Python<'py>, @@ -11,26 +9,10 @@ pub fn cdlbelthold<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - let body = body_size(o, c); - if range == 0.0 { - continue; - } - let long_body = body >= range * 0.6; - if is_bullish(o, c) && long_body && (o - l).abs() <= range * 0.01 { - result[i] = 100; - } else if is_bearish(o, c) && long_body && (h - o).abs() <= range * 0.01 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlbelthold(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlbreakaway.rs b/src/pattern/cdlbreakaway.rs index 6329f80..ec298f9 100644 --- a/src/pattern/cdlbreakaway.rs +++ b/src/pattern/cdlbreakaway.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlbreakaway<'py>( py: Python<'py>, @@ -11,46 +9,10 @@ pub fn cdlbreakaway<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 4..n { - let (o0, h0, l0, c0) = (opens[i - 4], highs[i - 4], lows[i - 4], closes[i - 4]); - let c1 = closes[i - 3]; - let c2 = closes[i - 2]; - let c3 = closes[i - 1]; - let _l3 = lows[i - 1]; - let _h3 = highs[i - 1]; - let (o4, c4) = (opens[i], closes[i]); - let range0 = candle_range(h0, l0); - let body0 = body_size(o0, c0); - if is_bearish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && c1 < l0 - && c2 < c1 - && c3 < c2 - && is_bullish(o4, c4) - && c4 > c1 - && c4 < c0 - { - result[i] = 100; - } else if is_bullish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && c1 > h0 - && c2 > c1 - && c3 > c2 - && is_bearish(o4, c4) - && c4 < c1 - && c4 > c0 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlbreakaway(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlclosingmarubozu.rs b/src/pattern/cdlclosingmarubozu.rs index 81bd85a..b63138d 100644 --- a/src/pattern/cdlclosingmarubozu.rs +++ b/src/pattern/cdlclosingmarubozu.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlclosingmarubozu<'py>( py: Python<'py>, @@ -11,28 +9,10 @@ pub fn cdlclosingmarubozu<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - if body < range * 0.4 { - continue; - } - if is_bullish(o, c) && (h - c).abs() <= range * 0.01 { - result[i] = 100; - } else if is_bearish(o, c) && (c - l).abs() <= range * 0.01 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlclosingmarubozu(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlconcealbabyswall.rs b/src/pattern/cdlconcealbabyswall.rs index e134fb3..5e719ac 100644 --- a/src/pattern/cdlconcealbabyswall.rs +++ b/src/pattern/cdlconcealbabyswall.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlconcealbabyswall<'py>( py: Python<'py>, @@ -11,41 +9,10 @@ pub fn cdlconcealbabyswall<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 3..n { - let (o0, h0, l0, c0) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]); - let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, h2, l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let range1 = candle_range(h1, l1); - let maru0 = range0 > 0.0 - && upper_shadow(o0, h0, c0) <= range0 * 0.02 - && lower_shadow(o0, l0, c0) <= range0 * 0.02; - let maru1 = range1 > 0.0 - && upper_shadow(o1, h1, c1) <= range1 * 0.02 - && lower_shadow(o1, l1, c1) <= range1 * 0.02; - let gap_down = o2 < c1; - let shadow_into = h2 >= c1; - let engulfs = o3 >= o2 && c3 <= c2 && h3 >= h2 && l3 <= l2; - if is_bearish(o0, c0) - && is_bearish(o1, c1) - && maru0 - && maru1 - && is_bearish(o2, c2) - && gap_down - && shadow_into - && is_bearish(o3, c3) - && engulfs - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlconcealbabyswall(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlcounterattack.rs b/src/pattern/cdlcounterattack.rs index 21c062d..13c5be2 100644 --- a/src/pattern/cdlcounterattack.rs +++ b/src/pattern/cdlcounterattack.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlcounterattack<'py>( py: Python<'py>, @@ -11,28 +9,10 @@ pub fn cdlcounterattack<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, h1, l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let range1 = candle_range(h1, l1); - let body0 = body_size(o0, c0); - let body1 = body_size(o1, c1); - let long0 = range0 > 0.0 && body0 >= range0 * 0.5; - let long1 = range1 > 0.0 && body1 >= range1 * 0.5; - let same_close = (c1 - c0).abs() <= range0 * 0.02; - if is_bearish(o0, c0) && long0 && is_bullish(o1, c1) && long1 && same_close { - result[i] = 100; - } else if is_bullish(o0, c0) && long0 && is_bearish(o1, c1) && long1 && same_close { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlcounterattack(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdldarkcloudcover.rs b/src/pattern/cdldarkcloudcover.rs index 1a05932..049c279 100644 --- a/src/pattern/cdldarkcloudcover.rs +++ b/src/pattern/cdldarkcloudcover.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdldarkcloudcover<'py>( py: Python<'py>, @@ -11,29 +9,10 @@ pub fn cdldarkcloudcover<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let body0 = body_size(o0, c0); - let range0 = candle_range(h0, l0); - let midpoint0 = (o0 + c0) / 2.0; - if is_bullish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.5 - && is_bearish(o1, c1) - && o1 > h0 - && c1 < midpoint0 - && c1 > o0 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdldarkcloudcover(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdldoji.rs b/src/pattern/cdldoji.rs index 2a63814..0a2a875 100644 --- a/src/pattern/cdldoji.rs +++ b/src/pattern/cdldoji.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdldoji<'py>( py: Python<'py>, @@ -11,20 +9,10 @@ pub fn cdldoji<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let body = body_size(opens[i], closes[i]); - let range = candle_range(highs[i], lows[i]); - // Doji: body is very small relative to range - if range > 0.0 && body / range <= 0.1 { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdldoji(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdldojistar.rs b/src/pattern/cdldojistar.rs index 9ada6cb..6bcf3cb 100644 --- a/src/pattern/cdldojistar.rs +++ b/src/pattern/cdldojistar.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdldojistar<'py>( py: Python<'py>, @@ -11,36 +9,10 @@ pub fn cdldojistar<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let range1 = candle_range(h1, l1); - let range2 = candle_range(h2, l2); - - let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; - // Doji: body <= 10% of range - let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; - - // Bullish Doji Star: prior bearish large candle, doji opens/closes below prior low - let gap_down = o2.max(c2) < l1; - if is_bearish(o1, c1) && large_body1 && is_doji2 && gap_down { - result[i] = 100; - } - // Bearish Doji Star: prior bullish large candle, doji opens/closes above prior high - let gap_up = o2.min(c2) > h1; - if is_bullish(o1, c1) && large_body1 && is_doji2 && gap_up { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdldojistar(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdldragonflydoji.rs b/src/pattern/cdldragonflydoji.rs index d06038f..74b6255 100644 --- a/src/pattern/cdldragonflydoji.rs +++ b/src/pattern/cdldragonflydoji.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdldragonflydoji<'py>( py: Python<'py>, @@ -11,25 +9,10 @@ pub fn cdldragonflydoji<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - let us = upper_shadow(o, h, c); - let ls = lower_shadow(o, l, c); - if body / range <= 0.1 && us / range <= 0.1 && ls >= range * 0.6 { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdldragonflydoji(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlengulfing.rs b/src/pattern/cdlengulfing.rs index 4bd56d4..fcbf6d3 100644 --- a/src/pattern/cdlengulfing.rs +++ b/src/pattern/cdlengulfing.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlengulfing<'py>( py: Python<'py>, @@ -11,40 +9,10 @@ pub fn cdlengulfing<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let prev_o = opens[i - 1]; - let prev_c = closes[i - 1]; - let curr_o = opens[i]; - let curr_c = closes[i]; - - let prev_body_high = prev_o.max(prev_c); - let prev_body_low = prev_o.min(prev_c); - let curr_body_high = curr_o.max(curr_c); - let curr_body_low = curr_o.min(curr_c); - - // Bullish engulfing: prev is bearish, current is bullish and engulfs - if is_bearish(prev_o, prev_c) - && is_bullish(curr_o, curr_c) - && curr_body_high > prev_body_high - && curr_body_low < prev_body_low - { - result[i] = 100; - } - // Bearish engulfing: prev is bullish, current is bearish and engulfs - else if is_bullish(prev_o, prev_c) - && is_bearish(curr_o, curr_c) - && curr_body_high > prev_body_high - && curr_body_low < prev_body_low - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlengulfing(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdleveningdojistar.rs b/src/pattern/cdleveningdojistar.rs index 581fae7..39115d2 100644 --- a/src/pattern/cdleveningdojistar.rs +++ b/src/pattern/cdleveningdojistar.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdleveningdojistar<'py>( py: Python<'py>, @@ -11,38 +9,10 @@ pub fn cdleveningdojistar<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let body3 = body_size(o3, c3); - let range1 = candle_range(h1, l1); - let range2 = candle_range(o2.min(c2) - DOJI_BODY_EPSILON, o2.max(c2)); - let range3 = candle_range(h3, l3); - - let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; - let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; - let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; - - if is_bullish(o1, c1) - && large_body1 - && is_doji2 - && is_bearish(o3, c3) - && large_body3 - && c3 < (o1 + c1) / 2.0 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdleveningdojistar(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdleveningstar.rs b/src/pattern/cdleveningstar.rs index 22469e3..2f6dc9a 100644 --- a/src/pattern/cdleveningstar.rs +++ b/src/pattern/cdleveningstar.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdleveningstar<'py>( py: Python<'py>, @@ -11,41 +9,10 @@ pub fn cdleveningstar<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let body3 = body_size(o3, c3); - let range1 = candle_range(h1, l1); - let range3 = candle_range(h3, l3); - - // Evening star conditions: - // 1. First candle is a large bullish candle - // 2. Second candle is a star (small body) gapping above first - // 3. Third candle is a large bearish candle - let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; - let small_body2 = range1 > 0.0 && body2 < body1 * 0.3; - let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; - - if is_bullish(o1, c1) - && large_body1 - && small_body2 - && is_bearish(o3, c3) - && large_body3 - && c3 < (o1 + c1) / 2.0 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdleveningstar(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlgapsidesidewhite.rs b/src/pattern/cdlgapsidesidewhite.rs index 185d320..b481e49 100644 --- a/src/pattern/cdlgapsidesidewhite.rs +++ b/src/pattern/cdlgapsidesidewhite.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlgapsidesidewhite<'py>( py: Python<'py>, @@ -11,27 +9,10 @@ pub fn cdlgapsidesidewhite<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, _h0, _l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let both_bullish = is_bullish(o1, c1) && is_bullish(o2, c2); - let similar_size = body1 > 0.0 && (body2 - body1).abs() / body1 <= 0.3; - let similar_open = body1 > 0.0 && (o2 - o1).abs() / body1 <= 0.3; - if is_bullish(o0, c0) && both_bullish && similar_size && similar_open && o1 > c0 { - result[i] = 100; - } else if is_bearish(o0, c0) && both_bullish && similar_size && similar_open && c1 < o0 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlgapsidesidewhite(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlgravestonedoji.rs b/src/pattern/cdlgravestonedoji.rs index 324f45d..0b7f919 100644 --- a/src/pattern/cdlgravestonedoji.rs +++ b/src/pattern/cdlgravestonedoji.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlgravestonedoji<'py>( py: Python<'py>, @@ -11,25 +9,10 @@ pub fn cdlgravestonedoji<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - let us = upper_shadow(o, h, c); - let ls = lower_shadow(o, l, c); - if body / range <= 0.1 && ls / range <= 0.1 && us >= range * 0.6 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlgravestonedoji(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlhammer.rs b/src/pattern/cdlhammer.rs index 9d9be7a..6bcc282 100644 --- a/src/pattern/cdlhammer.rs +++ b/src/pattern/cdlhammer.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlhammer<'py>( py: Python<'py>, @@ -11,24 +9,10 @@ pub fn cdlhammer<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let body = body_size(opens[i], closes[i]); - let range = candle_range(highs[i], lows[i]); - let lower = lower_shadow(opens[i], lows[i], closes[i]); - let upper = upper_shadow(opens[i], highs[i], closes[i]); - - // Hammer: small body (< 1/3 range), long lower shadow (>= 2x body), small upper shadow - if range > 0.0 && body > 0.0 && body <= range / 3.0 && lower >= 2.0 * body && upper <= body - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhammer(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlhangingman.rs b/src/pattern/cdlhangingman.rs index 8025958..c52a4d2 100644 --- a/src/pattern/cdlhangingman.rs +++ b/src/pattern/cdlhangingman.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlhangingman<'py>( py: Python<'py>, @@ -11,25 +9,10 @@ pub fn cdlhangingman<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - let us = upper_shadow(o, h, c); - let ls = lower_shadow(o, l, c); - if range > 0.0 && body > 0.0 && ls >= body * 2.0 && us <= body && body / range <= 0.4 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhangingman(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlharami.rs b/src/pattern/cdlharami.rs index 1aedb2f..cc16e4b 100644 --- a/src/pattern/cdlharami.rs +++ b/src/pattern/cdlharami.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlharami<'py>( py: Python<'py>, @@ -11,39 +9,10 @@ pub fn cdlharami<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let range1 = candle_range(h1, l1); - - let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; - - // Current candle body is inside prior candle body - let body1_high = o1.max(c1); - let body1_low = o1.min(c1); - let body2_high = o2.max(c2); - let body2_low = o2.min(c2); - - let inside = body2_high <= body1_high && body2_low >= body1_low && body2 < body1 * 0.6; - - // Bullish Harami: prior bearish, current bullish inside - if is_bearish(o1, c1) && large_body1 && inside && is_bullish(o2, c2) { - result[i] = 100; - } - // Bearish Harami: prior bullish, current bearish inside - else if is_bullish(o1, c1) && large_body1 && inside && is_bearish(o2, c2) { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlharami(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlharamicross.rs b/src/pattern/cdlharamicross.rs index 547d51b..bb947ee 100644 --- a/src/pattern/cdlharamicross.rs +++ b/src/pattern/cdlharamicross.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlharamicross<'py>( py: Python<'py>, @@ -11,41 +9,10 @@ pub fn cdlharamicross<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let range1 = candle_range(h1, l1); - let range2 = candle_range(h2, l2); - - let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; - - // Second candle must be a doji - let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; - - // Doji body must be inside prior body - let body1_high = o1.max(c1); - let body1_low = o1.min(c1); - let doji_mid = (o2 + c2) / 2.0; - let inside = doji_mid <= body1_high && doji_mid >= body1_low; - - // Bullish Harami Cross: prior bearish large candle, doji inside - if is_bearish(o1, c1) && large_body1 && is_doji2 && inside { - result[i] = 100; - } - // Bearish Harami Cross: prior bullish large candle, doji inside - else if is_bullish(o1, c1) && large_body1 && is_doji2 && inside { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlharamicross(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlhighwave.rs b/src/pattern/cdlhighwave.rs index e6b043a..889df79 100644 --- a/src/pattern/cdlhighwave.rs +++ b/src/pattern/cdlhighwave.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlhighwave<'py>( py: Python<'py>, @@ -11,29 +9,10 @@ pub fn cdlhighwave<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - let us = upper_shadow(o, h, c); - let ls = lower_shadow(o, l, c); - if body / range <= 0.3 && us >= range * 0.3 && ls >= range * 0.3 { - if is_bullish(o, c) { - result[i] = 100; - } else { - result[i] = -100; - } - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhighwave(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlhikkake.rs b/src/pattern/cdlhikkake.rs index bd9ee11..34e1712 100644 --- a/src/pattern/cdlhikkake.rs +++ b/src/pattern/cdlhikkake.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlhikkake<'py>( py: Python<'py>, @@ -11,28 +9,10 @@ pub fn cdlhikkake<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let h1 = highs[i - 1]; - let l1 = lows[i - 1]; - let h2 = highs[i]; - let l2 = lows[i]; - let inside = h1 <= h0 && l1 >= l0; - if !inside { - continue; - } - if is_bearish(o0, c0) && h2 > h1 && l2 > l1 { - result[i] = 100; - } else if is_bullish(o0, c0) && l2 < l1 && h2 < h1 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhikkake(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlhikkakemod.rs b/src/pattern/cdlhikkakemod.rs index e0b7e5c..2866bb5 100644 --- a/src/pattern/cdlhikkakemod.rs +++ b/src/pattern/cdlhikkakemod.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlhikkakemod<'py>( py: Python<'py>, @@ -11,30 +9,10 @@ pub fn cdlhikkakemod<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 3..n { - let (o0, h0, l0, c0) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]); - let h1 = highs[i - 2]; - let l1 = lows[i - 2]; - let h2 = highs[i - 1]; - let l2 = lows[i - 1]; - let h3 = highs[i]; - let l3 = lows[i]; - let inside = h1 <= h0 && l1 >= l0; - if !inside { - continue; - } - if is_bearish(o0, c0) && l2 < l1 && h3 > h1 && l3 > l1 { - result[i] = 100; - } else if is_bullish(o0, c0) && h2 > h1 && l3 < l1 && h3 < h1 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhikkakemod(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlhomingpigeon.rs b/src/pattern/cdlhomingpigeon.rs index 8eb43aa..e3d08c8 100644 --- a/src/pattern/cdlhomingpigeon.rs +++ b/src/pattern/cdlhomingpigeon.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlhomingpigeon<'py>( py: Python<'py>, @@ -11,27 +9,10 @@ pub fn cdlhomingpigeon<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, _h0, _l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let body0_high = o0.max(c0); - let body0_low = o0.min(c0); - let body1_high = o1.max(c1); - let body1_low = o1.min(c1); - if is_bearish(o0, c0) - && is_bearish(o1, c1) - && body1_high <= body0_high - && body1_low >= body0_low - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhomingpigeon(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlidentical3crows.rs b/src/pattern/cdlidentical3crows.rs index 137393c..a5eeaa6 100644 --- a/src/pattern/cdlidentical3crows.rs +++ b/src/pattern/cdlidentical3crows.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlidentical3crows<'py>( py: Python<'py>, @@ -11,34 +9,10 @@ pub fn cdlidentical3crows<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let range1 = candle_range(h1, l1); - let tol0 = range0 * 0.03; - let tol1 = range1 * 0.03; - if is_bearish(o0, c0) - && is_bearish(o1, c1) - && is_bearish(o2, c2) - && c1 < c0 - && c2 < c1 - && (o1 - c0).abs() <= tol0 - && (o2 - c1).abs() <= tol1 - && range0 > 0.0 - && range1 > 0.0 - && candle_range(h2, l2) > 0.0 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlidentical3crows(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlinneck.rs b/src/pattern/cdlinneck.rs index b1f13da..a43a910 100644 --- a/src/pattern/cdlinneck.rs +++ b/src/pattern/cdlinneck.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlinneck<'py>( py: Python<'py>, @@ -11,27 +9,10 @@ pub fn cdlinneck<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let body0 = body_size(o0, c0); - if is_bearish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && is_bullish(o1, c1) - && o1 < l0 - && (c1 - c0).abs() <= range0 * 0.03 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlinneck(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlinvertedhammer.rs b/src/pattern/cdlinvertedhammer.rs index f940f21..4568de8 100644 --- a/src/pattern/cdlinvertedhammer.rs +++ b/src/pattern/cdlinvertedhammer.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlinvertedhammer<'py>( py: Python<'py>, @@ -11,25 +9,10 @@ pub fn cdlinvertedhammer<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - let us = upper_shadow(o, h, c); - let ls = lower_shadow(o, l, c); - if body > 0.0 && us >= body * 2.0 && ls <= body && body / range <= 0.4 { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlinvertedhammer(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlkicking.rs b/src/pattern/cdlkicking.rs index 5a261fe..133ca2e 100644 --- a/src/pattern/cdlkicking.rs +++ b/src/pattern/cdlkicking.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlkicking<'py>( py: Python<'py>, @@ -11,29 +9,10 @@ pub fn cdlkicking<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, h1, l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let range1 = candle_range(h1, l1); - let maru0 = range0 > 0.0 - && upper_shadow(o0, h0, c0) <= range0 * 0.02 - && lower_shadow(o0, l0, c0) <= range0 * 0.02; - let maru1 = range1 > 0.0 - && upper_shadow(o1, h1, c1) <= range1 * 0.02 - && lower_shadow(o1, l1, c1) <= range1 * 0.02; - if is_bearish(o0, c0) && maru0 && is_bullish(o1, c1) && maru1 && o1 > o0 { - result[i] = 100; - } else if is_bullish(o0, c0) && maru0 && is_bearish(o1, c1) && maru1 && o1 < o0 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlkicking(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlkickingbylength.rs b/src/pattern/cdlkickingbylength.rs index f9aaf12..9be040c 100644 --- a/src/pattern/cdlkickingbylength.rs +++ b/src/pattern/cdlkickingbylength.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlkickingbylength<'py>( py: Python<'py>, @@ -11,40 +9,10 @@ pub fn cdlkickingbylength<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, h1, l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let range1 = candle_range(h1, l1); - let maru0 = range0 > 0.0 - && upper_shadow(o0, h0, c0) <= range0 * 0.02 - && lower_shadow(o0, l0, c0) <= range0 * 0.02; - let maru1 = range1 > 0.0 - && upper_shadow(o1, h1, c1) <= range1 * 0.02 - && lower_shadow(o1, l1, c1) <= range1 * 0.02; - let opposite = (is_bearish(o0, c0) && is_bullish(o1, c1)) - || (is_bullish(o0, c0) && is_bearish(o1, c1)); - let has_gap = (o1 - c0).abs() > 0.0; - if maru0 && maru1 && opposite && has_gap { - if range1 >= range0 { - if is_bullish(o1, c1) { - result[i] = 100; - } else { - result[i] = -100; - } - } else if is_bullish(o0, c0) { - result[i] = 100; - } else { - result[i] = -100; - } - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlkickingbylength(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlladderbottom.rs b/src/pattern/cdlladderbottom.rs index 5955110..1cc9d88 100644 --- a/src/pattern/cdlladderbottom.rs +++ b/src/pattern/cdlladderbottom.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlladderbottom<'py>( py: Python<'py>, @@ -11,30 +9,10 @@ pub fn cdlladderbottom<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 4..n { - let (o0, _h0, _l0, c0) = (opens[i - 4], highs[i - 4], lows[i - 4], closes[i - 4]); - let (o1, _h1, _l1, c1) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]); - let (o2, _h2, _l2, c2) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o3, h3, _l3, c3) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o4, h4, l4, c4) = (opens[i], highs[i], lows[i], closes[i]); - let three_bear = is_bearish(o0, c0) && is_bearish(o1, c1) && is_bearish(o2, c2); - let descend = c1 < c0 && c2 < c1; - let us3 = upper_shadow(o3, h3, c3); - let body3 = body_size(o3, c3); - let inv_hammer = us3 >= body3 * 1.5; - let range4 = candle_range(h4, l4); - let body4 = body_size(o4, c4); - let large_bull = is_bullish(o4, c4) && range4 > 0.0 && body4 >= range4 * 0.5; - if three_bear && descend && inv_hammer && large_bull && c4 > c2 { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlladderbottom(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdllongleggeddoji.rs b/src/pattern/cdllongleggeddoji.rs index 2a435c2..e9dc5c6 100644 --- a/src/pattern/cdllongleggeddoji.rs +++ b/src/pattern/cdllongleggeddoji.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdllongleggeddoji<'py>( py: Python<'py>, @@ -11,25 +9,10 @@ pub fn cdllongleggeddoji<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - let us = upper_shadow(o, h, c); - let ls = lower_shadow(o, l, c); - if body / range <= 0.1 && us >= range * 0.3 && ls >= range * 0.3 { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdllongleggeddoji(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdllongline.rs b/src/pattern/cdllongline.rs index fe3c952..63bf7a7 100644 --- a/src/pattern/cdllongline.rs +++ b/src/pattern/cdllongline.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdllongline<'py>( py: Python<'py>, @@ -11,27 +9,10 @@ pub fn cdllongline<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - if body >= range * 0.7 { - if is_bullish(o, c) { - result[i] = 100; - } else { - result[i] = -100; - } - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdllongline(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlmarubozu.rs b/src/pattern/cdlmarubozu.rs index cd6a0f9..0c146d6 100644 --- a/src/pattern/cdlmarubozu.rs +++ b/src/pattern/cdlmarubozu.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlmarubozu<'py>( py: Python<'py>, @@ -11,27 +9,10 @@ pub fn cdlmarubozu<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let body = body_size(opens[i], closes[i]); - let range = candle_range(highs[i], lows[i]); - let lower = lower_shadow(opens[i], lows[i], closes[i]); - let upper = upper_shadow(opens[i], highs[i], closes[i]); - - // Marubozu: body is >= 95% of range, tiny or no shadows - if range > 0.0 && body >= range * 0.95 && upper <= range * 0.025 && lower <= range * 0.025 { - if is_bullish(opens[i], closes[i]) { - result[i] = 100; - } else { - result[i] = -100; - } - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmarubozu(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlmatchinglow.rs b/src/pattern/cdlmatchinglow.rs index 6b13f6d..7bc3cfe 100644 --- a/src/pattern/cdlmatchinglow.rs +++ b/src/pattern/cdlmatchinglow.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlmatchinglow<'py>( py: Python<'py>, @@ -11,21 +9,10 @@ pub fn cdlmatchinglow<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let tol = range0 * 0.02; - if is_bearish(o0, c0) && is_bearish(o1, c1) && (c1 - c0).abs() <= tol { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmatchinglow(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlmathold.rs b/src/pattern/cdlmathold.rs index fbc6a06..7e76e74 100644 --- a/src/pattern/cdlmathold.rs +++ b/src/pattern/cdlmathold.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlmathold<'py>( py: Python<'py>, @@ -11,30 +9,10 @@ pub fn cdlmathold<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 4..n { - let (o0, h0, l0, c0) = (opens[i - 4], highs[i - 4], lows[i - 4], closes[i - 4]); - let (o1, _h1, l1, c1) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]); - let (o2, _h2, l2, c2) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o3, _h3, l3, c3) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o4, h4, l4, c4) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let body0 = body_size(o0, c0); - let range4 = candle_range(h4, l4); - let body4 = body_size(o4, c4); - let large_bull0 = is_bullish(o0, c0) && range0 > 0.0 && body0 >= range0 * 0.5; - let small_bears = is_bearish(o1, c1) && is_bearish(o2, c2) && is_bearish(o3, c3); - let stay_above = l1 >= o0 && l2 >= o0 && l3 >= o0; - let large_bull4 = is_bullish(o4, c4) && range4 > 0.0 && body4 >= range4 * 0.5 && c4 > c0; - if large_bull0 && small_bears && stay_above && large_bull4 { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmathold(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlmorningdojistar.rs b/src/pattern/cdlmorningdojistar.rs index 17fb3f0..be45eb2 100644 --- a/src/pattern/cdlmorningdojistar.rs +++ b/src/pattern/cdlmorningdojistar.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlmorningdojistar<'py>( py: Python<'py>, @@ -11,39 +9,10 @@ pub fn cdlmorningdojistar<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let body3 = body_size(o3, c3); - let range1 = candle_range(h1, l1); - let range2 = candle_range(o2.min(c2) - DOJI_BODY_EPSILON, o2.max(c2)); // avoid div-by-zero - let range3 = candle_range(h3, l3); - - let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; - // Middle candle must be a doji - let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; - let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; - - if is_bearish(o1, c1) - && large_body1 - && is_doji2 - && is_bullish(o3, c3) - && large_body3 - && c3 > (o1 + c1) / 2.0 - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmorningdojistar(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlmorningstar.rs b/src/pattern/cdlmorningstar.rs index b33595e..ef2f68e 100644 --- a/src/pattern/cdlmorningstar.rs +++ b/src/pattern/cdlmorningstar.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlmorningstar<'py>( py: Python<'py>, @@ -11,41 +9,10 @@ pub fn cdlmorningstar<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o1, h1, l1, c1) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o2, _h2, _l2, c2) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i]); - - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let body3 = body_size(o3, c3); - let range1 = candle_range(h1, l1); - let range3 = candle_range(h3, l3); - - // Morning star conditions: - // 1. First candle is a large bearish candle - // 2. Second candle is a star (small body) gapping below first - // 3. Third candle is a large bullish candle - let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; - let small_body2 = range1 > 0.0 && body2 < body1 * 0.3; - let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; - - if is_bearish(o1, c1) - && large_body1 - && small_body2 - && is_bullish(o3, c3) - && large_body3 - && c3 > (o1 + c1) / 2.0 - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmorningstar(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlonneck.rs b/src/pattern/cdlonneck.rs index 487adbe..3b3a933 100644 --- a/src/pattern/cdlonneck.rs +++ b/src/pattern/cdlonneck.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlonneck<'py>( py: Python<'py>, @@ -11,27 +9,10 @@ pub fn cdlonneck<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let body0 = body_size(o0, c0); - if is_bearish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && is_bullish(o1, c1) - && o1 < l0 - && (c1 - l0).abs() <= range0 * 0.03 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlonneck(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlpiercing.rs b/src/pattern/cdlpiercing.rs index 0f0eebc..7b862cb 100644 --- a/src/pattern/cdlpiercing.rs +++ b/src/pattern/cdlpiercing.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlpiercing<'py>( py: Python<'py>, @@ -11,29 +9,10 @@ pub fn cdlpiercing<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let body0 = body_size(o0, c0); - let range0 = candle_range(h0, l0); - let midpoint0 = (o0 + c0) / 2.0; - if is_bearish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && is_bullish(o1, c1) - && o1 < l0 - && c1 > midpoint0 - && c1 < o0 - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlpiercing(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlrickshawman.rs b/src/pattern/cdlrickshawman.rs index e14bae2..a10d3d7 100644 --- a/src/pattern/cdlrickshawman.rs +++ b/src/pattern/cdlrickshawman.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlrickshawman<'py>( py: Python<'py>, @@ -11,30 +9,10 @@ pub fn cdlrickshawman<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - let us = upper_shadow(o, h, c); - let ls = lower_shadow(o, l, c); - let body_mid = (o + c) / 2.0; - let range_mid = (h + l) / 2.0; - let is_doji = body / range <= 0.1; - let long_shadows = us >= range * 0.3 && ls >= range * 0.3; - let near_center = (body_mid - range_mid).abs() <= range * 0.15; - if is_doji && long_shadows && near_center { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlrickshawman(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlrisefall3methods.rs b/src/pattern/cdlrisefall3methods.rs index a950396..a35d5ed 100644 --- a/src/pattern/cdlrisefall3methods.rs +++ b/src/pattern/cdlrisefall3methods.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlrisefall3methods<'py>( py: Python<'py>, @@ -11,62 +9,10 @@ pub fn cdlrisefall3methods<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 4..n { - let (o0, h0, l0, c0) = (opens[i - 4], highs[i - 4], lows[i - 4], closes[i - 4]); - let (o1, h1, l1, c1) = (opens[i - 3], highs[i - 3], lows[i - 3], closes[i - 3]); - let (o2, h2, l2, c2) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o3, h3, l3, c3) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o4, h4, l4, c4) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let body0 = body_size(o0, c0); - let range4 = candle_range(h4, l4); - let body4 = body_size(o4, c4); - if is_bullish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.5 - && is_bearish(o1, c1) - && is_bearish(o2, c2) - && is_bearish(o3, c3) - && h1 <= h0 - && l1 >= l0 - && h2 <= h0 - && l2 >= l0 - && h3 <= h0 - && l3 >= l0 - && is_bullish(o4, c4) - && range4 > 0.0 - && body4 >= range4 * 0.5 - && c4 > c0 - && o4 > c3 - { - result[i] = 100; - } else if is_bearish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.5 - && is_bullish(o1, c1) - && is_bullish(o2, c2) - && is_bullish(o3, c3) - && h1 <= h0 - && l1 >= l0 - && h2 <= h0 - && l2 >= l0 - && h3 <= h0 - && l3 >= l0 - && is_bearish(o4, c4) - && range4 > 0.0 - && body4 >= range4 * 0.5 - && c4 < c0 - && o4 < c3 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlrisefall3methods(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlseparatinglines.rs b/src/pattern/cdlseparatinglines.rs index 8d6f06c..f59ec8b 100644 --- a/src/pattern/cdlseparatinglines.rs +++ b/src/pattern/cdlseparatinglines.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlseparatinglines<'py>( py: Python<'py>, @@ -11,26 +9,10 @@ pub fn cdlseparatinglines<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, h1, l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let body1 = body_size(o1, c1); - let range1 = candle_range(h1, l1); - let same_open = range0 > 0.0 && (o1 - o0).abs() <= range0 * 0.02; - let long1 = range1 > 0.0 && body1 >= range1 * 0.5; - if is_bearish(o0, c0) && is_bullish(o1, c1) && same_open && long1 { - result[i] = 100; - } else if is_bullish(o0, c0) && is_bearish(o1, c1) && same_open && long1 { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlseparatinglines(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlshootingstar.rs b/src/pattern/cdlshootingstar.rs index ebce3ed..4d0d214 100644 --- a/src/pattern/cdlshootingstar.rs +++ b/src/pattern/cdlshootingstar.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlshootingstar<'py>( py: Python<'py>, @@ -11,24 +9,10 @@ pub fn cdlshootingstar<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let body = body_size(opens[i], closes[i]); - let range = candle_range(highs[i], lows[i]); - let lower = lower_shadow(opens[i], lows[i], closes[i]); - let upper = upper_shadow(opens[i], highs[i], closes[i]); - - // Shooting star: small body, long upper shadow (>= 2x body), small lower shadow - if range > 0.0 && body > 0.0 && body <= range / 3.0 && upper >= 2.0 * body && lower <= body - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlshootingstar(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlshortline.rs b/src/pattern/cdlshortline.rs index 3071f61..b27dc52 100644 --- a/src/pattern/cdlshortline.rs +++ b/src/pattern/cdlshortline.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlshortline<'py>( py: Python<'py>, @@ -11,27 +9,10 @@ pub fn cdlshortline<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c); - if body > 0.0 && body <= range * 0.3 { - if is_bullish(o, c) { - result[i] = 100; - } else { - result[i] = -100; - } - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlshortline(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlspinningtop.rs b/src/pattern/cdlspinningtop.rs index fddc1f4..909c9c5 100644 --- a/src/pattern/cdlspinningtop.rs +++ b/src/pattern/cdlspinningtop.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlspinningtop<'py>( py: Python<'py>, @@ -11,27 +9,10 @@ pub fn cdlspinningtop<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let body = body_size(opens[i], closes[i]); - let range = candle_range(highs[i], lows[i]); - let lower = lower_shadow(opens[i], lows[i], closes[i]); - let upper = upper_shadow(opens[i], highs[i], closes[i]); - - // Spinning top: small body (< 1/3 range), both shadows longer than body - if range > 0.0 && body > 0.0 && body <= range / 3.0 && upper > body && lower > body { - if is_bullish(opens[i], closes[i]) { - result[i] = 100; - } else { - result[i] = -100; - } - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlspinningtop(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlstalledpattern.rs b/src/pattern/cdlstalledpattern.rs index a24d054..9bca03f 100644 --- a/src/pattern/cdlstalledpattern.rs +++ b/src/pattern/cdlstalledpattern.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlstalledpattern<'py>( py: Python<'py>, @@ -11,35 +9,10 @@ pub fn cdlstalledpattern<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let body0 = body_size(o0, c0); - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - if is_bullish(o0, c0) - && is_bullish(o1, c1) - && is_bullish(o2, c2) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && c1 > c0 - && c2 > c1 - && o1 >= o0 - && o1 <= c0 - && o2 >= c1 * 0.99 - && body2 < body1 * 0.7 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlstalledpattern(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlsticksandwich.rs b/src/pattern/cdlsticksandwich.rs index ef82d2b..ba7ebff 100644 --- a/src/pattern/cdlsticksandwich.rs +++ b/src/pattern/cdlsticksandwich.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlsticksandwich<'py>( py: Python<'py>, @@ -11,28 +9,10 @@ pub fn cdlsticksandwich<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let tol = range0 * 0.02; - if is_bearish(o0, c0) - && is_bullish(o1, c1) - && is_bearish(o2, c2) - && (c2 - c0).abs() <= tol - && o1 >= c0 - && c1 <= o0 - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlsticksandwich(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdltakuri.rs b/src/pattern/cdltakuri.rs index fd03687..7f9270f 100644 --- a/src/pattern/cdltakuri.rs +++ b/src/pattern/cdltakuri.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdltakuri<'py>( py: Python<'py>, @@ -11,25 +9,10 @@ pub fn cdltakuri<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 0..n { - let (o, h, l, c) = (opens[i], highs[i], lows[i], closes[i]); - let range = candle_range(h, l); - if range == 0.0 { - continue; - } - let body = body_size(o, c) + DOJI_BODY_EPSILON; - let ls = lower_shadow(o, l, c); - let us = upper_shadow(o, h, c); - if ls >= body * 3.0 && us <= range * 0.1 { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdltakuri(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdltasukigap.rs b/src/pattern/cdltasukigap.rs index 3896a91..640e690 100644 --- a/src/pattern/cdltasukigap.rs +++ b/src/pattern/cdltasukigap.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdltasukigap<'py>( py: Python<'py>, @@ -11,38 +9,10 @@ pub fn cdltasukigap<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, _h0, _l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - if is_bullish(o0, c0) - && is_bullish(o1, c1) - && o1 > c0 - && is_bearish(o2, c2) - && o2 >= l1 - && o2 <= c1 - && c2 > c0 - && c2 < o1 - { - result[i] = 100; - } else if is_bearish(o0, c0) - && is_bearish(o1, c1) - && o1 < c0 - && is_bullish(o2, c2) - && o2 >= c1 - && o2 <= h1 - && c2 < c0 - && c2 > o1 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdltasukigap(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlthrusting.rs b/src/pattern/cdlthrusting.rs index d5934e3..4b01824 100644 --- a/src/pattern/cdlthrusting.rs +++ b/src/pattern/cdlthrusting.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlthrusting<'py>( py: Python<'py>, @@ -11,29 +9,10 @@ pub fn cdlthrusting<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 1..n { - let (o0, h0, l0, c0) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o1, _h1, _l1, c1) = (opens[i], highs[i], lows[i], closes[i]); - let body0 = body_size(o0, c0); - let range0 = candle_range(h0, l0); - let midpoint0 = (o0 + c0) / 2.0; - if is_bearish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && is_bullish(o1, c1) - && o1 < l0 - && c1 > c0 - && c1 < midpoint0 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlthrusting(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdltristar.rs b/src/pattern/cdltristar.rs index f936a5a..53311fa 100644 --- a/src/pattern/cdltristar.rs +++ b/src/pattern/cdltristar.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdltristar<'py>( py: Python<'py>, @@ -11,33 +9,10 @@ pub fn cdltristar<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let range1 = candle_range(h1, l1); - let range2 = candle_range(h2, l2); - let body0 = body_size(o0, c0); - let body1 = body_size(o1, c1); - let body2 = body_size(o2, c2); - let doji0 = range0 > 0.0 && body0 / range0 <= 0.1; - let doji1 = range1 > 0.0 && body1 / range1 <= 0.1; - let doji2 = range2 > 0.0 && body2 / range2 <= 0.1; - if doji0 && doji1 && doji2 { - if l1 < l0 && h1 < h0 && c2 > c1 { - result[i] = 100; - } else if l1 > l0 && h1 > h0 && c2 < c1 { - result[i] = -100; - } - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdltristar(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlunique3river.rs b/src/pattern/cdlunique3river.rs index f5c50bd..379cd5f 100644 --- a/src/pattern/cdlunique3river.rs +++ b/src/pattern/cdlunique3river.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlunique3river<'py>( py: Python<'py>, @@ -11,35 +9,10 @@ pub fn cdlunique3river<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, _h1, l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, h2, l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let body0 = body_size(o0, c0); - let body2 = body_size(o2, c2); - let range2 = candle_range(h2, l2); - if is_bearish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && is_bearish(o1, c1) - && l1 < l0 - && lower_shadow(o1, l1, c1) > 0.0 - && is_bullish(o2, c2) - && range2 > 0.0 - && body2 <= range2 * 0.5 - && c2 < c1 - && c2 > l1 - { - result[i] = 100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlunique3river(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlupsidegap2crows.rs b/src/pattern/cdlupsidegap2crows.rs index 190bfaf..18cce70 100644 --- a/src/pattern/cdlupsidegap2crows.rs +++ b/src/pattern/cdlupsidegap2crows.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlupsidegap2crows<'py>( py: Python<'py>, @@ -11,31 +9,10 @@ pub fn cdlupsidegap2crows<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, h0, l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - let range0 = candle_range(h0, l0); - let body0 = body_size(o0, c0); - if is_bullish(o0, c0) - && range0 > 0.0 - && body0 >= range0 * 0.4 - && is_bearish(o1, c1) - && o1 > c0 - && is_bearish(o2, c2) - && o2 > o1 - && c2 < o1 - && c2 > c0 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlupsidegap2crows(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/cdlxsidegap3methods.rs b/src/pattern/cdlxsidegap3methods.rs index a6d9c20..3664f92 100644 --- a/src/pattern/cdlxsidegap3methods.rs +++ b/src/pattern/cdlxsidegap3methods.rs @@ -1,8 +1,6 @@ use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -use super::common::*; - #[pyfunction] pub fn cdlxsidegap3methods<'py>( py: Python<'py>, @@ -11,38 +9,10 @@ pub fn cdlxsidegap3methods<'py>( low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let opens = open.as_slice()?; - let highs = high.as_slice()?; - let lows = low.as_slice()?; - let closes = close.as_slice()?; - let n = opens.len(); - super::common::validate_ohlc_length(n, highs.len(), lows.len(), closes.len())?; - let mut result = vec![0i32; n]; - for i in 2..n { - let (o0, _h0, _l0, c0) = (opens[i - 2], highs[i - 2], lows[i - 2], closes[i - 2]); - let (o1, _h1, _l1, c1) = (opens[i - 1], highs[i - 1], lows[i - 1], closes[i - 1]); - let (o2, _h2, _l2, c2) = (opens[i], highs[i], lows[i], closes[i]); - if is_bullish(o0, c0) - && is_bullish(o1, c1) - && o1 > c0 - && is_bearish(o2, c2) - && o2 <= c1 - && o2 >= o1 - && c2 >= c0 - && c2 <= o1 - { - result[i] = 100; - } else if is_bearish(o0, c0) - && is_bearish(o1, c1) - && o1 < c0 - && is_bullish(o2, c2) - && o2 >= c1 - && o2 <= o1 - && c2 <= c0 - && c2 >= o1 - { - result[i] = -100; - } - } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlxsidegap3methods(o, h, l, c); Ok(result.into_pyarray(py)) } diff --git a/src/pattern/common.rs b/src/pattern/common.rs index ffb462f..9b12440 100644 --- a/src/pattern/common.rs +++ b/src/pattern/common.rs @@ -1,51 +1 @@ -//! Shared helpers for candlestick pattern detection. - -use pyo3::prelude::PyResult; - -/// Validate that open, high, low, close arrays have the same length (for use in CDL* functions). -pub fn validate_ohlc_length( - open_len: usize, - high_len: usize, - low_len: usize, - close_len: usize, -) -> PyResult<()> { - crate::validation::validate_equal_length(&[ - (open_len, "open"), - (high_len, "high"), - (low_len, "low"), - (close_len, "close"), - ]) -} - -/// Epsilon for doji-like candles (body ≈ 0) to avoid division by zero. -pub const DOJI_BODY_EPSILON: f64 = 0.0001; - -#[inline] -pub fn body_size(open: f64, close: f64) -> f64 { - (close - open).abs() -} - -#[inline] -pub fn upper_shadow(open: f64, high: f64, close: f64) -> f64 { - high - open.max(close) -} - -#[inline] -pub fn lower_shadow(open: f64, low: f64, close: f64) -> f64 { - open.min(close) - low -} - -#[inline] -pub fn candle_range(high: f64, low: f64) -> f64 { - high - low -} - -#[inline] -pub fn is_bullish(open: f64, close: f64) -> bool { - close >= open -} - -#[inline] -pub fn is_bearish(open: f64, close: f64) -> bool { - close < open -} +// Common helpers are now in ferro_ta_core::pattern. This file is kept for mod declaration. diff --git a/src/portfolio/mod.rs b/src/portfolio/mod.rs index 3ce3eba..8ee9402 100644 --- a/src/portfolio/mod.rs +++ b/src/portfolio/mod.rs @@ -1,4 +1,4 @@ -//! Portfolio Analytics — Rust implementations. +//! Portfolio Analytics — thin PyO3 wrappers delegating to `ferro_ta_core::portfolio`. //! //! Compute-intensive portfolio metrics implemented in Rust: //! - `portfolio_volatility` — sqrt(w' Σ w) given weights and a covariance matrix @@ -43,16 +43,11 @@ pub fn portfolio_volatility<'py>( "cov_matrix must be ({n}, {n}), got ({rows}, {cols})" ))); } - // variance = w' Σ w - let mut variance = 0.0_f64; - for i in 0..n { - let mut row_sum = 0.0_f64; - for j in 0..n { - row_sum += w[j] * cov[[i, j]]; - } - variance += w[i] * row_sum; - } - Ok(variance.max(0.0).sqrt()) + // Convert ndarray rows to Vec> for core + let cov_rows: Vec> = (0..rows) + .map(|i| (0..cols).map(|j| cov[[i, j]]).collect()) + .collect(); + Ok(ferro_ta_core::portfolio::portfolio_volatility(&cov_rows, w)) } // --------------------------------------------------------------------------- @@ -83,22 +78,7 @@ pub fn beta_full<'py>( "asset_returns and benchmark_returns must have equal length >= 2", )); } - let mean_a: f64 = a.iter().sum::() / n as f64; - let mean_b: f64 = b.iter().sum::() / n as f64; - let mut cov = 0.0_f64; - let mut var_b = 0.0_f64; - for i in 0..n { - let da = a[i] - mean_a; - let db = b[i] - mean_b; - cov += da * db; - var_b += db * db; - } - if var_b == 0.0 { - return Err(PyValueError::new_err( - "benchmark_returns has zero variance; cannot compute beta", - )); - } - Ok(cov / var_b) + Ok(ferro_ta_core::portfolio::beta_full(a, b)) } // --------------------------------------------------------------------------- @@ -134,23 +114,7 @@ pub fn rolling_beta<'py>( "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 = &a[start..=i]; - let b_win = &b[start..=i]; - let mean_a: f64 = a_win.iter().sum::() / window as f64; - let mean_b: f64 = b_win.iter().sum::() / 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 }; - } + let result = ferro_ta_core::portfolio::rolling_beta(a, b, window); Ok(result.into_pyarray(py)) } @@ -177,27 +141,10 @@ pub fn drawdown_series<'py>( equity: PyReadonlyArray1<'py, f64>, ) -> PyResult<(Bound<'py, PyArray1>, f64)> { let eq = equity.as_slice()?; - let n = eq.len(); - if n == 0 { + if eq.is_empty() { return Err(PyValueError::new_err("equity must be non-empty")); } - let mut dd = vec![0.0_f64; n]; - let mut peak = eq[0]; - let mut max_dd = 0.0_f64; - for i in 0..n { - if eq[i] > peak { - peak = eq[i]; - } - let d = if peak == 0.0 { - 0.0 - } else { - (eq[i] - peak) / peak - }; - dd[i] = d; - if d < max_dd { - max_dd = d; - } - } + let (dd, max_dd) = ferro_ta_core::portfolio::drawdown_series(eq); Ok((dd.into_pyarray(py), max_dd)) } @@ -224,45 +171,18 @@ pub fn correlation_matrix<'py>( if n_bars < 2 { return Err(PyValueError::new_err("data must have at least 2 rows")); } + // Core expects column vectors: data[j][i] = asset j at bar i + let columns: Vec> = (0..n_assets) + .map(|j| (0..n_bars).map(|i| arr[[i, j]]).collect()) + .collect(); + let corr = ferro_ta_core::portfolio::correlation_matrix(&columns); + // Convert Vec> back to ndarray::Array2 let mut result = Array2::::zeros((n_assets, n_assets)); - - // Compute means - let mut means = vec![0.0_f64; n_assets]; - for j in 0..n_assets { - let mut sum = 0.0; - for i in 0..n_bars { - sum += arr[[i, j]]; - } - means[j] = sum / n_bars as f64; - } - - // Compute std devs and covariances - let mut stds = vec![0.0_f64; n_assets]; - for j in 0..n_assets { - let mut var = 0.0; - for i in 0..n_bars { - let d = arr[[i, j]] - means[j]; - var += d * d; - } - stds[j] = (var / n_bars as f64).sqrt(); - } - for j1 in 0..n_assets { for j2 in 0..n_assets { - if j1 == j2 { - result[[j1, j2]] = 1.0; - } else { - let mut cov = 0.0; - for i in 0..n_bars { - cov += (arr[[i, j1]] - means[j1]) * (arr[[i, j2]] - means[j2]); - } - cov /= n_bars as f64; - let denom = stds[j1] * stds[j2]; - result[[j1, j2]] = if denom == 0.0 { f64::NAN } else { cov / denom }; - } + result[[j1, j2]] = corr[j1][j2]; } } - Ok(result.into_pyarray(py)) } @@ -297,18 +217,7 @@ pub fn relative_strength<'py>( "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 + a[i]; - cum_b *= 1.0 + b[i]; - result[i] = if cum_b == 0.0 { - f64::NAN - } else { - cum_a / cum_b - }; - } + let result = ferro_ta_core::portfolio::relative_strength(a, b); Ok(result.into_pyarray(py)) } @@ -342,11 +251,7 @@ pub fn spread<'py>( "a and b must be non-empty and equal length", )); } - let result: Vec = av - .iter() - .zip(bv.iter()) - .map(|(x, y)| x - hedge * y) - .collect(); + let result = ferro_ta_core::portfolio::spread(av, bv, hedge); Ok(result.into_pyarray(py)) } @@ -371,11 +276,7 @@ pub fn ratio<'py>( "a and b must be non-empty and equal length", )); } - let result: Vec = av - .iter() - .zip(bv.iter()) - .map(|(&x, &y)| if y == 0.0 { f64::NAN } else { x / y }) - .collect(); + let result = ferro_ta_core::portfolio::ratio(av, bv); Ok(result.into_pyarray(py)) } @@ -406,22 +307,10 @@ pub fn zscore_series<'py>( return Err(PyValueError::new_err("window must be >= 2")); } let xv = x.as_slice()?; - let n = xv.len(); - if n == 0 { + if xv.is_empty() { return Err(PyValueError::new_err("x must be non-empty")); } - let mut result = vec![f64::NAN; n]; - for i in (window - 1)..n { - let win = &xv[i + 1 - window..=i]; - let mean: f64 = win.iter().sum::() / window as f64; - let var: f64 = win.iter().map(|v| (v - mean).powi(2)).sum::() / window as f64; - let std = var.sqrt(); - result[i] = if std == 0.0 { - f64::NAN - } else { - (xv[i] - mean) / std - }; - } + let result = ferro_ta_core::portfolio::zscore_series(xv, window); Ok(result.into_pyarray(py)) } @@ -455,14 +344,11 @@ pub fn compose_weighted<'py>( n_sigs ))); } - let mut result = vec![0.0_f64; n_bars]; - for i in 0..n_bars { - let mut s = 0.0; - for j in 0..n_sigs { - s += arr[[i, j]] * w[j]; - } - result[i] = s; - } + // Core expects column vectors: data[j][i] = signal j at bar i + let columns: Vec> = (0..n_sigs) + .map(|j| (0..n_bars).map(|i| arr[[i, j]]).collect()) + .collect(); + let result = ferro_ta_core::portfolio::compose_weighted(&columns, w); Ok(result.into_pyarray(py)) } diff --git a/src/price_transform/avgprice.rs b/src/price_transform/avgprice.rs index d837667..c892553 100644 --- a/src/price_transform/avgprice.rs +++ b/src/price_transform/avgprice.rs @@ -22,12 +22,6 @@ pub fn avgprice<'py>( (lows.len(), "low"), (closes.len(), "close"), ])?; - let result: Vec = opens - .iter() - .zip(highs.iter()) - .zip(lows.iter()) - .zip(closes.iter()) - .map(|(((&o, &h), &l), &c)| (o + h + l + c) / 4.0) - .collect(); + let result = ferro_ta_core::price_transform::avgprice(opens, highs, lows, closes); Ok(result.into_pyarray(py)) } diff --git a/src/price_transform/medprice.rs b/src/price_transform/medprice.rs index c40f5f2..d47356b 100644 --- a/src/price_transform/medprice.rs +++ b/src/price_transform/medprice.rs @@ -13,10 +13,6 @@ pub fn medprice<'py>( let lows = low.as_slice()?; let n = highs.len(); validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; - let result: Vec = highs - .iter() - .zip(lows.iter()) - .map(|(&h, &l)| (h + l) / 2.0) - .collect(); + let result = ferro_ta_core::price_transform::medprice(highs, lows); Ok(result.into_pyarray(py)) } diff --git a/src/price_transform/typprice.rs b/src/price_transform/typprice.rs index d04af99..7ac07ac 100644 --- a/src/price_transform/typprice.rs +++ b/src/price_transform/typprice.rs @@ -19,11 +19,6 @@ pub fn typprice<'py>( (lows.len(), "low"), (closes.len(), "close"), ])?; - let result: Vec = highs - .iter() - .zip(lows.iter()) - .zip(closes.iter()) - .map(|((&h, &l), &c)| (h + l + c) / 3.0) - .collect(); + let result = ferro_ta_core::price_transform::typprice(highs, lows, closes); Ok(result.into_pyarray(py)) } diff --git a/src/price_transform/wclprice.rs b/src/price_transform/wclprice.rs index fa275d5..ae3d466 100644 --- a/src/price_transform/wclprice.rs +++ b/src/price_transform/wclprice.rs @@ -19,11 +19,6 @@ pub fn wclprice<'py>( (lows.len(), "low"), (closes.len(), "close"), ])?; - let result: Vec = highs - .iter() - .zip(lows.iter()) - .zip(closes.iter()) - .map(|((&h, &l), &c)| (h + l + c * 2.0) / 4.0) - .collect(); + let result = ferro_ta_core::price_transform::wclprice(highs, lows, closes); Ok(result.into_pyarray(py)) } diff --git a/src/regime/mod.rs b/src/regime/mod.rs index 02ed676..f53bf99 100644 --- a/src/regime/mod.rs +++ b/src/regime/mod.rs @@ -1,36 +1,12 @@ -//! Regime detection and structural breaks. -//! -//! Functions -//! --------- -//! - `regime_adx` — label each bar as trend (1) or range (0) -//! using an ADX threshold. -//! - `regime_combined` — combine ADX + ATR-ratio rule for more robust -//! regime labelling. -//! - `detect_breaks_cusum` — detect structural breaks using a CUSUM-style -//! cumulative sum approach; returns a binary mask. -//! - `rolling_variance_break` — find indices where rolling variance changes -//! significantly (volatility regime break). +//! Regime detection and structural breaks (thin PyO3 wrapper over ferro_ta_core::regime). use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -// --------------------------------------------------------------------------- -// regime_adx -// --------------------------------------------------------------------------- +use crate::validation; /// Label each bar as **trend** (1) or **range** (0) based on ADX level. -/// -/// A bar is labelled "trend" when ``adx[i] > threshold`` (default 25). -/// -/// Parameters -/// ---------- -/// adx : 1-D float64 array — ADX values (NaN during warm-up) -/// threshold : float — ADX level above which a bar is "trending" (default 25.0) -/// -/// Returns -/// ------- -/// 1-D int8 array — ``1`` = trend, ``0`` = range, ``-1`` = NaN (warm-up) #[pyfunction] pub fn regime_adx<'py>( py: Python<'py>, @@ -38,44 +14,11 @@ pub fn regime_adx<'py>( threshold: f64, ) -> PyResult>> { let a = adx.as_slice()?; - let out: Vec = a - .iter() - .map(|&v| { - if v.is_nan() { - -1i8 - } else if v > threshold { - 1i8 - } else { - 0i8 - } - }) - .collect(); - Ok(out.into_pyarray(py)) + let result = ferro_ta_core::regime::regime_adx(a, threshold); + Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// regime_combined -// --------------------------------------------------------------------------- - /// 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`` -/// -/// The second condition (ATR as % of price) ensures that the trend has -/// meaningful volatility (avoids labelling flat micro-trends as trending). -/// -/// Parameters -/// ---------- -/// adx : 1-D float64 — ADX values -/// atr : 1-D float64 — ATR values -/// close : 1-D float64 — close prices (for ATR normalisation) -/// adx_threshold : float — ADX threshold (default 25.0) -/// atr_pct_threshold : float — minimum ATR/close ratio (default 0.005 = 0.5%) -/// -/// Returns -/// ------- -/// 1-D int8 — ``1`` = trend, ``0`` = range, ``-1`` = NaN #[pyfunction] pub fn regime_combined<'py>( py: Python<'py>, @@ -89,52 +32,12 @@ pub fn regime_combined<'py>( let r = atr.as_slice()?; let c = close.as_slice()?; let n = a.len(); - if n != r.len() || n != c.len() { - return Err(PyValueError::new_err( - "adx, atr, and close must have the same length", - )); - } - let out: Vec = (0..n) - .map(|i| { - let av = a[i]; - let rv = r[i]; - let cv = c[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(); - Ok(out.into_pyarray(py)) + validation::validate_equal_length(&[(n, "adx"), (r.len(), "atr"), (c.len(), "close")])?; + let result = ferro_ta_core::regime::regime_combined(a, r, c, adx_threshold, atr_pct_threshold); + Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// detect_breaks_cusum -// --------------------------------------------------------------------------- - -/// Detect structural breaks using a CUSUM (cumulative sum) approach. -/// -/// CUSUM accumulates deviations from a rolling mean. When the cumulative -/// sum exceeds ``threshold * std(series)``, a break is flagged. -/// -/// Algorithm (simplified one-sided CUSUM on demeaned series): -/// 1. Compute a rolling mean and std over *window* bars. -/// 2. Accumulate the standardised deviation: ``S_i = max(0, S_{i-1} + z_i - slack)``. -/// 3. When ``S_i > threshold``, mark a break and reset the accumulator. -/// -/// Parameters -/// ---------- -/// series : 1-D float64 array — the series to monitor (e.g. close prices) -/// window : int — lookback for mean/std estimation (>= 2) -/// threshold : float — CUSUM threshold in units of std (default 3.0) -/// slack : float — allowance term (default 0.5) -/// -/// Returns -/// ------- -/// 1-D int8 array — ``1`` at break bars, ``0`` elsewhere +/// Detect structural breaks using a CUSUM approach. #[pyfunction] pub fn detect_breaks_cusum<'py>( py: Python<'py>, @@ -143,60 +46,13 @@ pub fn detect_breaks_cusum<'py>( threshold: f64, slack: f64, ) -> PyResult>> { - if window < 2 { - return Err(PyValueError::new_err("window must be >= 2")); - } + validation::validate_timeperiod(window, "window", 2)?; let s = series.as_slice()?; - let n = s.len(); - let mut out = vec![0i8; n]; - if n < window { - return Ok(out.into_pyarray(py)); - } - let mut cusum_pos = 0.0_f64; - let mut cusum_neg = 0.0_f64; - for i in window..n { - // Rolling mean and std over [i-window, i) - let slice = &s[(i - window)..i]; - let mean: f64 = slice.iter().sum::() / window as f64; - let var: f64 = - slice.iter().map(|&v| (v - mean) * (v - mean)).sum::() / (window - 1) as f64; - let std = var.sqrt(); - if std == 0.0 || std.is_nan() || s[i].is_nan() { - continue; - } - let z = (s[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; - } - } - Ok(out.into_pyarray(py)) + let result = ferro_ta_core::regime::detect_breaks_cusum(s, window, threshold, slack); + Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// rolling_variance_break -// --------------------------------------------------------------------------- - -/// Detect volatility regime breaks using a rolling variance change test. -/// -/// Compares the variance in a short lookback window (*short_window*) to a -/// longer reference window (*long_window*). When their ratio exceeds -/// *threshold*, a volatility break is flagged. -/// -/// Parameters -/// ---------- -/// series : 1-D float64 array — returns or price series -/// short_window : int — short lookback for recent variance (>= 2) -/// long_window : int — long lookback for baseline variance (> short_window) -/// threshold : float — ratio short_var / long_var above which a break fires -/// (default 2.0) -/// -/// Returns -/// ------- -/// 1-D int8 array — ``1`` at break bars, ``0`` elsewhere +/// Detect volatility regime breaks using rolling variance ratio. #[pyfunction] pub fn rolling_variance_break<'py>( py: Python<'py>, @@ -205,44 +61,17 @@ pub fn rolling_variance_break<'py>( long_window: usize, threshold: f64, ) -> PyResult>> { - if short_window < 2 { - return Err(PyValueError::new_err("short_window must be >= 2")); - } + validation::validate_timeperiod(short_window, "short_window", 2)?; if long_window <= short_window { - return Err(PyValueError::new_err("long_window must be > short_window")); + return Err(PyValueError::new_err( + "long_window must be > short_window", + )); } let s = series.as_slice()?; - let n = s.len(); - let mut out = vec![0i8; n]; - if n < long_window { - return Ok(out.into_pyarray(py)); - } - - let variance = |slice: &[f64]| -> f64 { - let k = slice.len(); - let mean: f64 = slice.iter().sum::() / k as f64; - slice.iter().map(|&v| (v - mean) * (v - mean)).sum::() / (k - 1) as f64 - }; - - for i in long_window..n { - let long_slice = &s[(i - long_window)..i]; - let short_slice = &s[(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; - } - } - Ok(out.into_pyarray(py)) + let result = ferro_ta_core::regime::rolling_variance_break(s, short_window, long_window, threshold); + Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// Register -// --------------------------------------------------------------------------- - pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(regime_adx, m)?)?; m.add_function(wrap_pyfunction!(regime_combined, m)?)?; diff --git a/src/resampling/mod.rs b/src/resampling/mod.rs index 982a588..8f5668d 100644 --- a/src/resampling/mod.rs +++ b/src/resampling/mod.rs @@ -1,18 +1,9 @@ -//! Resampling — OHLCV resampling and multi-timeframe helpers. -//! -//! Provides volume-bar resampling and OHLCV aggregation primitives. -//! Time-based resampling (pandas rule strings) is handled in the Python layer; -//! this module provides the compute-heavy parts that benefit from Rust. -//! -//! # Functions -//! - `volume_bars` — Aggregate ticks/bars into bars of fixed volume size. -//! - `ohlcv_agg` — Aggregate an array of OHLCV bars given bar-index labels. +//! Resampling — OHLCV resampling (thin PyO3 wrapper over ferro_ta_core::resampling). 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>, Bound<'py, PyArray1>, @@ -21,30 +12,7 @@ type Ohlcv5<'py> = ( Bound<'py, PyArray1>, ); -// --------------------------------------------------------------------------- -// 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 five 1-D arrays: (open, high, low, close, volume). -/// -/// Parameters -/// ---------- -/// open, high, low, close, volume : 1-D float64 arrays (equal length) -/// volume_threshold : float — target volume per bar (must be > 0) -/// -/// Returns -/// ------- -/// Tuple of five 1-D float64 arrays (open, high, low, close, volume). #[pyfunction] #[pyo3(signature = (open, high, low, close, volume, volume_threshold))] pub fn volume_bars<'py>( @@ -70,76 +38,17 @@ pub fn volume_bars<'py>( "All input arrays must be non-empty and have equal length", )); } - - let mut out_open: Vec = Vec::new(); - let mut out_high: Vec = Vec::new(); - let mut out_low: Vec = Vec::new(); - let mut out_close: Vec = Vec::new(); - let mut out_vol: Vec = Vec::new(); - - let mut bar_open = o[0]; - let mut bar_high = h[0]; - let mut bar_low = l[0]; - let mut bar_close = c[0]; - let mut bar_vol = v[0]; - - for i in 1..n { - bar_high = bar_high.max(h[i]); - bar_low = bar_low.min(l[i]); - bar_close = c[i]; - bar_vol += v[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 = o[i + 1]; - bar_high = h[i + 1]; - bar_low = l[i + 1]; - bar_close = c[i + 1]; - bar_vol = v[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); - } - + let (ro, rh, rl, rc, rv) = ferro_ta_core::resampling::volume_bars(o, h, l, c, v, volume_threshold); Ok(( - 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), + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), )) } -// --------------------------------------------------------------------------- -// ohlcv_agg -// --------------------------------------------------------------------------- - /// Aggregate OHLCV bars by integer group labels. -/// -/// Given OHLCV arrays and a `labels` array of non-negative integers (same -/// length), 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 five 1-D arrays: (open, high, low, close, volume). #[pyfunction] #[pyo3(signature = (open, high, low, close, volume, labels))] pub fn ohlcv_agg<'py>( @@ -163,59 +72,16 @@ pub fn ohlcv_agg<'py>( "All input arrays must be non-empty and have equal length", )); } - - let mut out_open: Vec = Vec::new(); - let mut out_high: Vec = Vec::new(); - let mut out_low: Vec = Vec::new(); - let mut out_close: Vec = Vec::new(); - let mut out_vol: Vec = Vec::new(); - - let mut cur_label = lbl[0]; - let mut bar_open = o[0]; - let mut bar_high = h[0]; - let mut bar_low = l[0]; - let mut bar_close = c[0]; - let mut bar_vol = v[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); - cur_label = lbl[i]; - bar_open = o[i]; - bar_high = h[i]; - bar_low = l[i]; - bar_close = c[i]; - bar_vol = v[i]; - } else { - bar_high = bar_high.max(h[i]); - bar_low = bar_low.min(l[i]); - bar_close = c[i]; - bar_vol += v[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); - + let (ro, rh, rl, rc, rv) = ferro_ta_core::resampling::ohlcv_agg(o, h, l, c, v, lbl); Ok(( - 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), + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), )) } -// --------------------------------------------------------------------------- -// Register -// --------------------------------------------------------------------------- - pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(volume_bars, m)?)?; m.add_function(wrap_pyfunction!(ohlcv_agg, m)?)?; diff --git a/src/signals/mod.rs b/src/signals/mod.rs index da55cf0..0a1a845 100644 --- a/src/signals/mod.rs +++ b/src/signals/mod.rs @@ -1,75 +1,26 @@ -//! Signal processing helpers — Rust implementations. -//! -//! - `rank_series` — cross-sectional rank of a 1-D array (fractional rank) -//! - `top_n_indices` — indices of the N largest values in a 1-D array -//! - `bottom_n_indices` — indices of the N smallest values in a 1-D array +//! Signal processing helpers (thin PyO3 wrapper over ferro_ta_core::signals). use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1, PyReadonlyArray2}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -fn rank_values(xv: &[f64]) -> Vec { - let n = xv.len(); - let mut order: Vec = (0..n).collect(); - order.sort_by(|&a, &b| { - xv[a] - .partial_cmp(&xv[b]) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - let mut ranks = vec![0.0_f64; n]; - let mut i = 0; - while i < n { - let val = xv[order[i]]; - let mut j = i + 1; - while j < n && xv[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 -} - -// --------------------------------------------------------------------------- -// rank_series -// --------------------------------------------------------------------------- - /// Compute the fractional rank of each element (1-based, ascending). -/// -/// Ties receive the average of their rank positions (same as pandas default). -/// -/// Parameters -/// ---------- -/// x : 1-D float64 array -/// -/// Returns -/// ------- -/// 1-D float64 array — ranks in [1, n] +/// Ties receive the average of their rank positions. #[pyfunction] pub fn rank_series<'py>( py: Python<'py>, x: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { let xv = x.as_slice()?; - let n = xv.len(); - if n == 0 { + if xv.is_empty() { return Err(PyValueError::new_err("x must be non-empty")); } - Ok(rank_values(xv).into_pyarray(py)) + let result = ferro_ta_core::signals::rank_values(xv); + Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// compose_rank -// --------------------------------------------------------------------------- - /// Compute rank-based composite scores for a 2-D signal matrix. -/// -/// Each column is ranked independently (ascending, fractional ranks for ties), -/// and the per-row ranks are summed across columns. +/// Each column is ranked independently, per-row ranks are summed. #[pyfunction] pub fn compose_rank<'py>( py: Python<'py>, @@ -84,34 +35,17 @@ pub fn compose_rank<'py>( } let scores = py.allow_threads(|| { - let mut scores = vec![0.0_f64; n_bars]; - for sig_idx in 0..n_sigs { - let column: Vec = arr.column(sig_idx).iter().copied().collect(); - let ranks = rank_values(&column); - for (bar_idx, rank) in ranks.into_iter().enumerate() { - scores[bar_idx] += rank; - } - } - scores + let columns: Vec> = (0..n_sigs) + .map(|sig_idx| arr.column(sig_idx).iter().copied().collect()) + .collect(); + let col_refs: Vec<&[f64]> = columns.iter().map(|c| c.as_slice()).collect(); + ferro_ta_core::signals::compose_rank(&col_refs) }); Ok(scores.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// top_n_indices -// --------------------------------------------------------------------------- - -/// Return the indices of the N largest values in `x` (unsorted). -/// -/// Parameters -/// ---------- -/// x : 1-D float64 array -/// n : int — number of top elements to return -/// -/// Returns -/// ------- -/// 1-D int64 array of length min(n, len(x)) +/// Return the indices of the N largest values in `x`. #[pyfunction] pub fn top_n_indices<'py>( py: Python<'py>, @@ -119,23 +53,11 @@ pub fn top_n_indices<'py>( n: usize, ) -> PyResult>> { let xv = x.as_slice()?; - let len = xv.len(); - let k = n.min(len); - let mut order: Vec = (0..len).collect(); - order.sort_by(|&a, &b| { - xv[b] - .partial_cmp(&xv[a]) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let result: Vec = order[..k].iter().map(|&i| i as i64).collect(); + let result = ferro_ta_core::signals::top_n_indices(xv, n); Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// bottom_n_indices -// --------------------------------------------------------------------------- - -/// Return the indices of the N smallest values in `x` (unsorted). +/// Return the indices of the N smallest values in `x`. #[pyfunction] pub fn bottom_n_indices<'py>( py: Python<'py>, @@ -143,22 +65,10 @@ pub fn bottom_n_indices<'py>( n: usize, ) -> PyResult>> { let xv = x.as_slice()?; - let len = xv.len(); - let k = n.min(len); - let mut order: Vec = (0..len).collect(); - order.sort_by(|&a, &b| { - xv[a] - .partial_cmp(&xv[b]) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let result: Vec = order[..k].iter().map(|&i| i as i64).collect(); + let result = ferro_ta_core::signals::bottom_n_indices(xv, n); Ok(result.into_pyarray(py)) } -// --------------------------------------------------------------------------- -// Register -// --------------------------------------------------------------------------- - pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(rank_series, m)?)?; m.add_function(wrap_pyfunction!(compose_rank, m)?)?; diff --git a/src/streaming/mod.rs b/src/streaming/mod.rs index ce69a96..b831f9e 100644 --- a/src/streaming/mod.rs +++ b/src/streaming/mod.rs @@ -1,128 +1,18 @@ //! Streaming / Incremental Indicators — bar-by-bar stateful classes. //! -//! All classes are exposed as PyO3 `#[pyclass]` types. Each class: -//! - 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` property (where applicable). -//! -//! Internal EMA state is shared via the non-pyclass `EmaState` helper so -//! composite classes (`StreamingMACD`, `StreamingSupertrend`) can hold -//! multiple EMA states without additional allocations. - -use std::collections::VecDeque; +//! Thin PyO3 wrappers that delegate to `ferro_ta_core::streaming`. use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -// --------------------------------------------------------------------------- -// Internal helper: EMA state (not a pyclass — used inside composite classes) -// --------------------------------------------------------------------------- - -struct EmaState { - period: usize, - alpha: f64, - ema: f64, - seed_buf: Vec, - seeded: bool, -} - -impl EmaState { - 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, - } - } - - 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::() / self.period as f64; - self.ema = seed; - self.seeded = true; - log::debug!( - "EmaState warm-up complete: period={}, seed={seed:.6}", - self.period - ); - return seed; - } - self.ema += self.alpha * (value - self.ema); - self.ema - } - - fn reset(&mut self) { - self.ema = 0.0; - self.seed_buf.clear(); - self.seeded = false; - } -} +use ferro_ta_core::streaming as core; // --------------------------------------------------------------------------- -// Internal helper: ATR state (Wilder smoothing) +// Helper: convert core StreamingError to PyValueError // --------------------------------------------------------------------------- -struct AtrState { - period: usize, - prev_close: f64, - tr_buf: Vec, - atr: f64, - seeded: bool, - has_prev: bool, -} - -impl AtrState { - 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, - } - } - - 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::() / 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 - } - - fn reset(&mut self) { - self.prev_close = 0.0; - self.has_prev = false; - self.tr_buf.clear(); - self.atr = 0.0; - self.seeded = false; - } +fn to_py_err(e: core::StreamingError) -> PyErr { + PyValueError::new_err(e.0) } // --------------------------------------------------------------------------- @@ -134,10 +24,7 @@ impl AtrState { /// Returns NaN during the first `period - 1` bars. #[pyclass(module = "ferro_ta._ferro_ta")] pub struct StreamingSMA { - period: usize, - buf: VecDeque, - running_sum: f64, - count: usize, + inner: core::StreamingSMA, } #[pymethods] @@ -145,48 +32,28 @@ impl StreamingSMA { #[new] #[pyo3(signature = (period))] pub fn new(period: usize) -> PyResult { - if period < 1 { - return Err(PyValueError::new_err("period must be >= 1")); - } Ok(Self { - period, - buf: VecDeque::with_capacity(period + 1), - running_sum: 0.0, - count: 0, + inner: core::StreamingSMA::new(period).map_err(to_py_err)?, }) } /// 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 - } + self.inner.update(value) } /// Reset state to initial condition. pub fn reset(&mut self) { - self.buf.clear(); - self.running_sum = 0.0; - self.count = 0; + self.inner.reset(); } #[getter] pub fn period(&self) -> usize { - self.period + self.inner.period() } fn __repr__(&self) -> String { - format!("StreamingSMA(period={})", self.period) + format!("StreamingSMA(period={})", self.inner.period()) } } @@ -195,13 +62,9 @@ impl StreamingSMA { // --------------------------------------------------------------------------- /// 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. #[pyclass(module = "ferro_ta._ferro_ta")] pub struct StreamingEMA { - inner: EmaState, + inner: core::StreamingEMA, } #[pymethods] @@ -209,11 +72,8 @@ impl StreamingEMA { #[new] #[pyo3(signature = (period))] pub fn new(period: usize) -> PyResult { - if period < 1 { - return Err(PyValueError::new_err("period must be >= 1")); - } Ok(Self { - inner: EmaState::new(period), + inner: core::StreamingEMA::new(period).map_err(to_py_err)?, }) } @@ -228,11 +88,11 @@ impl StreamingEMA { #[getter] pub fn period(&self) -> usize { - self.inner.period + self.inner.period() } fn __repr__(&self) -> String { - format!("StreamingEMA(period={})", self.inner.period) + format!("StreamingEMA(period={})", self.inner.period()) } } @@ -241,18 +101,9 @@ impl StreamingEMA { // --------------------------------------------------------------------------- /// Relative Strength Index with TA-Lib–compatible Wilder seeding. -/// -/// Returns NaN during the first `period` bars. #[pyclass(module = "ferro_ta._ferro_ta")] pub struct StreamingRSI { - period: usize, - prev: f64, - has_prev: bool, - gains: Vec, - losses: Vec, - avg_gain: f64, - avg_loss: f64, - seeded: bool, + inner: core::StreamingRSI, } #[pymethods] @@ -260,73 +111,27 @@ impl StreamingRSI { #[new] #[pyo3(signature = (period = 14))] pub fn new(period: usize) -> PyResult { - if period < 1 { - return Err(PyValueError::new_err("period must be >= 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, + inner: core::StreamingRSI::new(period).map_err(to_py_err)?, }) } /// 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::() / self.period as f64; - self.avg_loss = self.losses.iter().sum::() / self.period as f64; - self.seeded = true; - log::debug!("StreamingRSI warm-up complete: period={}", self.period); - } 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) + self.inner.update(value) } 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; + self.inner.reset(); } #[getter] pub fn period(&self) -> usize { - self.period + self.inner.period() } fn __repr__(&self) -> String { - format!("StreamingRSI(period={})", self.period) + format!("StreamingRSI(period={})", self.inner.period()) } } @@ -335,12 +140,9 @@ impl StreamingRSI { // --------------------------------------------------------------------------- /// Average True Range with TA-Lib–compatible Wilder seeding. -/// -/// Accepts (high, low, close) per bar. -/// Returns NaN during the first `period` bars. #[pyclass(module = "ferro_ta._ferro_ta")] pub struct StreamingATR { - inner: AtrState, + inner: core::StreamingATR, } #[pymethods] @@ -348,11 +150,8 @@ impl StreamingATR { #[new] #[pyo3(signature = (period = 14))] pub fn new(period: usize) -> PyResult { - if period < 1 { - return Err(PyValueError::new_err("period must be >= 1")); - } Ok(Self { - inner: AtrState::new(period), + inner: core::StreamingATR::new(period).map_err(to_py_err)?, }) } @@ -367,11 +166,11 @@ impl StreamingATR { #[getter] pub fn period(&self) -> usize { - self.inner.period + self.inner.period() } fn __repr__(&self) -> String { - format!("StreamingATR(period={})", self.inner.period) + format!("StreamingATR(period={})", self.inner.period()) } } @@ -379,16 +178,10 @@ impl StreamingATR { // StreamingBBands // --------------------------------------------------------------------------- -/// Bollinger Bands — streaming variant. -/// -/// Returns (upper, middle, lower) as a Python tuple. -/// NaN tuple during the warmup window. +/// Bollinger Bands — streaming variant using Welford's online algorithm. #[pyclass(module = "ferro_ta._ferro_ta")] pub struct StreamingBBands { - period: usize, - nbdevup: f64, - nbdevdn: f64, - buf: VecDeque, + inner: core::StreamingBBands, } #[pymethods] @@ -396,54 +189,29 @@ impl StreamingBBands { #[new] #[pyo3(signature = (period = 20, nbdevup = 2.0, nbdevdn = 2.0))] pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> PyResult { - if period < 2 { - return Err(PyValueError::new_err("period must be >= 2")); - } Ok(Self { - period, - nbdevup, - nbdevdn, - buf: VecDeque::with_capacity(period + 1), + inner: core::StreamingBBands::new(period, nbdevup, nbdevdn).map_err(to_py_err)?, }) } /// Add a new bar; return (upper, middle, lower). NaN tuple during warmup. pub fn update(&mut self, value: f64) -> (f64, f64, f64) { - if self.buf.len() == self.period { - self.buf.pop_front(); - } - self.buf.push_back(value); - if self.buf.len() < self.period { - return (f64::NAN, f64::NAN, f64::NAN); - } - let n = self.period as f64; - // Single-pass: compute sum and sum-of-squares simultaneously - let mut sum = 0.0f64; - let mut sum_sq = 0.0f64; - for &x in &self.buf { - sum += x; - sum_sq += x * x; - } - let mean = sum / n; - // Sample variance: (Σx² - n·mean²) / (n-1) - let variance = (sum_sq - n * mean * mean).max(0.0) / (n - 1.0); - let std = variance.sqrt(); - (mean + self.nbdevup * std, mean, mean - self.nbdevdn * std) + self.inner.update(value) } pub fn reset(&mut self) { - self.buf.clear(); + self.inner.reset(); } #[getter] pub fn period(&self) -> usize { - self.period + self.inner.period() } fn __repr__(&self) -> String { format!( - "StreamingBBands(period={}, nbdevup={}, nbdevdn={})", - self.period, self.nbdevup, self.nbdevdn + "StreamingBBands(period={})", + self.inner.period() ) } } @@ -453,14 +221,9 @@ impl StreamingBBands { // --------------------------------------------------------------------------- /// MACD — fast EMA, slow EMA, signal EMA. -/// -/// Returns (macd_line, signal_line, histogram) as a Python tuple. -/// NaN values during warmup. #[pyclass(module = "ferro_ta._ferro_ta")] pub struct StreamingMACD { - fast: EmaState, - slow: EmaState, - signal: EmaState, + inner: core::StreamingMACD, } #[pymethods] @@ -468,46 +231,27 @@ impl StreamingMACD { #[new] #[pyo3(signature = (fastperiod = 12, slowperiod = 26, signalperiod = 9))] pub fn new(fastperiod: usize, slowperiod: usize, signalperiod: usize) -> PyResult { - if fastperiod >= slowperiod { - return Err(PyValueError::new_err("fastperiod must be < slowperiod")); - } - if fastperiod < 1 || signalperiod < 1 { - return Err(PyValueError::new_err("periods must be >= 1")); - } Ok(Self { - fast: EmaState::new(fastperiod), - slow: EmaState::new(slowperiod), - signal: EmaState::new(signalperiod), + inner: core::StreamingMACD::new(fastperiod, slowperiod, signalperiod) + .map_err(to_py_err)?, }) } /// 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) + self.inner.update(value) } pub fn reset(&mut self) { - self.fast.reset(); - self.slow.reset(); - self.signal.reset(); + self.inner.reset(); } fn __repr__(&self) -> String { format!( "StreamingMACD(fastperiod={}, slowperiod={}, signalperiod={})", - self.fast.period, self.slow.period, self.signal.period + self.inner.fast_period(), + self.inner.slow_period(), + self.inner.signal_period() ) } } @@ -517,19 +261,9 @@ impl StreamingMACD { // --------------------------------------------------------------------------- /// Slow Stochastic (SMA-smoothed). -/// -/// Returns (slowk, slowd) as a Python tuple. -/// NaN tuple during warmup. #[pyclass(module = "ferro_ta._ferro_ta")] pub struct StreamingStoch { - fastk_period: usize, - slowk_period: usize, - slowd_period: usize, - high_buf: VecDeque, - low_buf: VecDeque, - close_buf: VecDeque, - fastk_buf: VecDeque, - slowk_buf: VecDeque, + inner: core::StreamingStoch, } #[pymethods] @@ -537,83 +271,25 @@ impl StreamingStoch { #[new] #[pyo3(signature = (fastk_period = 5, slowk_period = 3, slowd_period = 3))] pub fn new(fastk_period: usize, slowk_period: usize, slowd_period: usize) -> PyResult { - if fastk_period < 1 || slowk_period < 1 || slowd_period < 1 { - return Err(PyValueError::new_err("all periods must be >= 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), - close_buf: VecDeque::with_capacity(fastk_period + 1), - fastk_buf: VecDeque::with_capacity(slowk_period + 1), - slowk_buf: VecDeque::with_capacity(slowd_period + 1), + inner: core::StreamingStoch::new(fastk_period, slowk_period, slowd_period) + .map_err(to_py_err)?, }) } /// 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.close_buf.pop_front(); - } - self.high_buf.push_back(high); - self.low_buf.push_back(low); - self.close_buf.push_back(close); - - 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::() / 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::() / self.slowd_period as f64; - (slowk, slowd) + self.inner.update(high, low, close) } pub fn reset(&mut self) { - self.high_buf.clear(); - self.low_buf.clear(); - self.close_buf.clear(); - self.fastk_buf.clear(); - self.slowk_buf.clear(); + self.inner.reset(); } fn __repr__(&self) -> String { format!( - "StreamingStoch(fastk_period={}, slowk_period={}, slowd_period={})", - self.fastk_period, self.slowk_period, self.slowd_period + "StreamingStoch(fastk_period={})", + self.inner.period() ) } } @@ -623,14 +299,9 @@ impl StreamingStoch { // --------------------------------------------------------------------------- /// Cumulative Volume Weighted Average Price. -/// -/// Resets automatically when `reset()` is called (e.g. at session open). -/// Accepts (high, low, close, volume) per bar. #[pyclass(module = "ferro_ta._ferro_ta")] -#[derive(Default)] pub struct StreamingVWAP { - cum_tpv: f64, - cum_vol: f64, + inner: core::StreamingVWAP, } #[pymethods] @@ -638,27 +309,18 @@ impl StreamingVWAP { #[new] pub fn new() -> Self { Self { - cum_tpv: 0.0, - cum_vol: 0.0, + inner: core::StreamingVWAP::new(), } } /// 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 - } + self.inner.update(high, low, close, volume) } /// Reset for a new session. pub fn reset(&mut self) { - self.cum_tpv = 0.0; - self.cum_vol = 0.0; + self.inner.reset(); } fn __repr__(&self) -> String { @@ -671,21 +333,9 @@ impl StreamingVWAP { // --------------------------------------------------------------------------- /// ATR-based Supertrend — streaming variant. -/// -/// Accepts (high, low, close) per bar. -/// Returns (supertrend_line, direction) as a Python tuple. -/// direction: 1 = uptrend, -1 = downtrend, 0 = warmup. #[pyclass(module = "ferro_ta._ferro_ta")] 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, + inner: core::StreamingSupertrend, } #[pymethods] @@ -693,101 +343,29 @@ impl StreamingSupertrend { #[new] #[pyo3(signature = (period = 7, multiplier = 3.0))] pub fn new(period: usize, multiplier: f64) -> PyResult { - if period < 1 { - return Err(PyValueError::new_err("period must be >= 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, + inner: core::StreamingSupertrend::new(period, multiplier).map_err(to_py_err)?, }) } /// 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) + self.inner.update(high, low, close) } 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; + self.inner.reset(); } #[getter] pub fn period(&self) -> usize { - self.period + self.inner.period() } fn __repr__(&self) -> String { format!( - "StreamingSupertrend(period={}, multiplier={})", - self.period, self.multiplier + "StreamingSupertrend(period={})", + self.inner.period() ) } } diff --git a/src/volume/ad.rs b/src/volume/ad.rs index 3e78560..995cedd 100644 --- a/src/volume/ad.rs +++ b/src/volume/ad.rs @@ -2,7 +2,7 @@ use crate::validation; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -/// Chaikin Accumulation/Distribution Line. Cumulates (close - low - (high - close)) / (high - low) * volume. +/// Chaikin Accumulation/Distribution Line. #[pyfunction] pub fn ad<'py>( py: Python<'py>, @@ -22,17 +22,6 @@ pub fn ad<'py>( (closes.len(), "close"), (vols.len(), "volume"), ])?; - let mut result = vec![0.0_f64; n]; - let mut ad_val = 0.0_f64; - for i in 0..n { - let hl = highs[i] - lows[i]; - let clv = if hl != 0.0 { - ((closes[i] - lows[i]) - (highs[i] - closes[i])) / hl - } else { - 0.0 - }; - ad_val += clv * vols[i]; - result[i] = ad_val; - } + let result = ferro_ta_core::volume::ad(highs, lows, closes, vols); Ok(result.into_pyarray(py)) } diff --git a/src/volume/adosc.rs b/src/volume/adosc.rs index 42d99c2..2a62874 100644 --- a/src/volume/adosc.rs +++ b/src/volume/adosc.rs @@ -2,8 +2,6 @@ use crate::validation; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use ta::indicators::ExponentialMovingAverage; -use ta::Next; /// Chaikin A/D Oscillator: fast EMA of AD minus slow EMA of AD. #[pyfunction] @@ -35,34 +33,6 @@ pub fn adosc<'py>( (closes.len(), "close"), (vols.len(), "volume"), ])?; - - // Compute raw AD values - let mut ad_vals = vec![0.0_f64; n]; - let mut ad_val = 0.0_f64; - for i in 0..n { - let hl = highs[i] - lows[i]; - let clv = if hl != 0.0 { - ((closes[i] - lows[i]) - (highs[i] - closes[i])) / hl - } else { - 0.0 - }; - ad_val += clv * vols[i]; - ad_vals[i] = ad_val; - } - - // Apply fast and slow EMA to AD - let mut fast_ema = ExponentialMovingAverage::new(fastperiod) - .map_err(|e| PyValueError::new_err(e.to_string()))?; - let mut slow_ema = ExponentialMovingAverage::new(slowperiod) - .map_err(|e| PyValueError::new_err(e.to_string()))?; - let warmup = slowperiod - 1; - let mut result = vec![f64::NAN; n]; - for (i, &v) in ad_vals.iter().enumerate() { - let fast = fast_ema.next(v); - let slow = slow_ema.next(v); - if i >= warmup { - result[i] = fast - slow; - } - } + let result = ferro_ta_core::volume::adosc(highs, lows, closes, vols, fastperiod, slowperiod); Ok(result.into_pyarray(py)) } diff --git a/src/volume/obv.rs b/src/volume/obv.rs index 832858d..480e87a 100644 --- a/src/volume/obv.rs +++ b/src/volume/obv.rs @@ -2,7 +2,7 @@ use crate::validation; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; -/// On Balance Volume: cumulates volume * sign(close - prev_close); bar 0 uses volume. +/// On Balance Volume: cumulates volume * sign(close - prev_close). #[pyfunction] pub fn obv<'py>( py: Python<'py>, @@ -13,15 +13,6 @@ pub fn obv<'py>( let vols = volume.as_slice()?; let n = closes.len(); validation::validate_equal_length(&[(n, "close"), (vols.len(), "volume")])?; - let mut result = vec![0.0_f64; n]; - let mut obv_val = 0.0_f64; - for i in 1..n { - if closes[i] > closes[i - 1] { - obv_val += vols[i]; - } else if closes[i] < closes[i - 1] { - obv_val -= vols[i]; - } - result[i] = obv_val; - } + let result = ferro_ta_core::volume::obv(closes, vols); Ok(result.into_pyarray(py)) } diff --git a/tests/unit/analysis/test_backtest_advanced.py b/tests/unit/analysis/test_backtest_advanced.py new file mode 100644 index 0000000..9b5229f --- /dev/null +++ b/tests/unit/analysis/test_backtest_advanced.py @@ -0,0 +1,2017 @@ +"""Tests for the advanced backtesting engine. + +Covers all 10 test groups from the plan: +1. backtest_ohlcv_core +2. compute_performance_metrics +3. extract_trades +4. backtest_multi_asset_core +5. monte_carlo_bootstrap +6. walk_forward_indices +7. kelly_fraction / half_kelly_fraction +8. BacktestEngine (Python API) +9. walk_forward() (Python API) +10. monte_carlo() (Python API) +""" + +from __future__ import annotations + +import math + +import numpy as np +import numpy.testing as npt +import pytest +from ferro_ta._ferro_ta import ( + backtest_core, + backtest_multi_asset_core, + backtest_ohlcv_core, + compute_performance_metrics, + drawdown_series, + half_kelly_fraction, + kelly_fraction, + monte_carlo_bootstrap, + walk_forward_indices, +) +from ferro_ta._ferro_ta import ( + extract_trades_ohlcv as extract_trades, +) + +from ferro_ta.analysis.backtest import ( + AdvancedBacktestResult, + BacktestEngine, + BacktestResult, + MonteCarloResult, + PortfolioBacktestResult, + WalkForwardResult, + backtest, + backtest_portfolio, + monte_carlo, + rsi_strategy, + walk_forward, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_ohlcv(n: int = 100, seed: int = 42) -> tuple: + rng = np.random.default_rng(seed) + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.005, n)) + high = close * (1 + rng.uniform(0, 0.01, n)) + low = close * (1 - rng.uniform(0, 0.01, n)) + signals = np.where(np.arange(n) % 20 < 10, 1.0, -1.0).astype(np.float64) + return open_, high, low, close, signals + + +def _all_finite(arr: np.ndarray) -> bool: + return bool(np.all(np.isfinite(arr[~np.isnan(arr)]))) + + +# =========================================================================== +# Group 1: backtest_ohlcv_core +# =========================================================================== + + +class TestBacktestOhlcvCore: + def test_returns_five_arrays(self): + o, h, l, c, s = _make_ohlcv() + result = backtest_ohlcv_core(o, h, l, c, s) + assert len(result) == 5 + + def test_shapes_match_input(self): + o, h, l, c, s = _make_ohlcv(n=80) + pos, fp, br, sr, eq = backtest_ohlcv_core(o, h, l, c, s) + for arr in (pos, fp, br, sr, eq): + assert arr.shape == (80,) + + def test_equity_starts_at_one(self): + o, h, l, c, s = _make_ohlcv() + _, _, _, _, eq = backtest_ohlcv_core(o, h, l, c, s) + assert eq[0] == pytest.approx(1.0, abs=1e-9) + + def test_no_lookahead_bias(self): + """Position at bar 0 must always be 0 (signal not yet available).""" + o, h, l, c, s = _make_ohlcv() + pos, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s) + assert pos[0] == 0.0 + + def test_stop_loss_reduces_equity_relative_to_no_stop(self): + """With a tight stop-loss, equity should differ from no-stop run.""" + o, h, l, c, s = _make_ohlcv(n=200) + _, _, _, _, eq_no_stop = backtest_ohlcv_core(o, h, l, c, s) + _, _, _, _, eq_with_stop = backtest_ohlcv_core( + o, h, l, c, s, stop_loss_pct=0.005 + ) + # They should differ (stop-loss triggered on at least one bar) + assert not np.allclose(eq_no_stop, eq_with_stop) + + def test_fill_prices_nan_when_flat(self): + """fill_prices must be NaN whenever the position is 0.""" + o, h, l, c, s = _make_ohlcv() + pos, fp, _, _, _ = backtest_ohlcv_core(o, h, l, c, s) + flat_mask = pos == 0.0 + assert np.all(np.isnan(fp[flat_mask])) + + def test_market_close_mode_different_from_open(self): + o, h, l, c, s = _make_ohlcv(n=150) + _, _, _, sr_open, _ = backtest_ohlcv_core( + o, h, l, c, s, fill_mode="market_open" + ) + _, _, _, sr_close, _ = backtest_ohlcv_core( + o, h, l, c, s, fill_mode="market_close" + ) + # Different fill modes → different returns + assert not np.allclose(sr_open, sr_close, equal_nan=True) + + def test_raises_on_mismatched_lengths(self): + o, h, l, c, s = _make_ohlcv() + with pytest.raises(Exception): + backtest_ohlcv_core(o[:-1], h, l, c, s) + + +# =========================================================================== +# Group 2: compute_performance_metrics +# =========================================================================== + + +class TestComputePerformanceMetrics: + EXPECTED_KEYS = { + "total_return", + "cagr", + "annualized_vol", + "sharpe", + "sortino", + "calmar", + "max_drawdown", + "avg_drawdown", + "max_drawdown_duration_bars", + "avg_drawdown_duration_bars", + "ulcer_index", + "omega_ratio", + "win_rate", + "profit_factor", + "r_expectancy", + "avg_win", + "avg_loss", + "tail_ratio", + "skewness", + "kurtosis", + "best_bar", + "worst_bar", + "n_trades", + } + + def _run(self, n: int = 200, seed: int = 0): + rng = np.random.default_rng(seed) + r = rng.standard_normal(n) * 0.01 + eq = np.cumprod(1 + r) + return compute_performance_metrics(r, eq) + + def test_all_expected_keys_present(self): + m = self._run() + assert self.EXPECTED_KEYS.issubset(set(m.keys())) + + def test_sharpe_all_positive_returns(self): + """Constant +1% daily returns → Sharpe = (annualised) > 0.""" + r = np.full(252, 0.01) + eq = np.cumprod(1 + r) + m = compute_performance_metrics(r, eq) + assert m["sharpe"] > 0 + + def test_max_drawdown_matches_drawdown_series(self): + rng = np.random.default_rng(7) + r = rng.standard_normal(300) * 0.015 + eq = np.cumprod(1 + r) + m = compute_performance_metrics(r, eq) + _, max_dd_ref = drawdown_series(eq) + assert m["max_drawdown"] == pytest.approx(max_dd_ref, abs=1e-9) + + def test_cagr_formula(self): + r = np.full(252, 0.01) + eq = np.cumprod(1 + r) + m = compute_performance_metrics(r, eq) + # Rust computes CAGR as (eq[-1]/eq[0])^(ppy/n) - 1, treating eq[0] as start equity + expected_cagr = (eq[-1] / eq[0]) ** (252.0 / len(r)) - 1.0 + assert m["cagr"] == pytest.approx(expected_cagr, rel=1e-6) + + def test_win_rate_between_0_and_1(self): + m = self._run() + assert 0.0 <= m["win_rate"] <= 1.0 + + def test_max_drawdown_nonpositive(self): + m = self._run() + assert m["max_drawdown"] <= 0.0 + + def test_total_return_sign(self): + r = np.full(100, 0.005) + eq = np.cumprod(1 + r) + m = compute_performance_metrics(r, eq) + assert m["total_return"] > 0.0 + + def test_raises_on_short_input(self): + with pytest.raises(Exception): + compute_performance_metrics(np.array([0.01]), np.array([1.01])) + + def test_raises_on_mismatched_lengths(self): + with pytest.raises(Exception): + compute_performance_metrics(np.ones(10) * 0.01, np.ones(20)) + + +# =========================================================================== +# Group 3: extract_trades +# =========================================================================== + + +class TestExtractTrades: + def _run_ohlcv(self, n: int = 100): + o, h, l, c, s = _make_ohlcv(n=n) + pos, fp, _, _, _ = backtest_ohlcv_core(o, h, l, c, s) + return pos, fp, h, l + + def test_returns_nine_arrays(self): + pos, fp, h, l = self._run_ohlcv() + result = extract_trades(pos, fp, h, l) + assert len(result) == 9 + + def test_all_arrays_same_length(self): + pos, fp, h, l = self._run_ohlcv(n=200) + arrays = extract_trades(pos, fp, h, l) + lengths = {len(a) for a in arrays} + assert len(lengths) == 1 # all same length + + def test_duration_bars_positive(self): + pos, fp, h, l = self._run_ohlcv(n=200) + _, _, _, _, _, _, dur, _, _ = extract_trades(pos, fp, h, l) + assert np.all(dur >= 0) + + def test_exit_bar_gte_entry_bar(self): + pos, fp, h, l = self._run_ohlcv(n=200) + eb, xb, _, _, _, _, _, _, _ = extract_trades(pos, fp, h, l) + assert np.all(xb >= eb) + + def test_direction_is_plus_minus_one(self): + pos, fp, h, l = self._run_ohlcv(n=200) + _, _, d, _, _, _, _, _, _ = extract_trades(pos, fp, h, l) + if len(d) > 0: + assert set(np.unique(d)).issubset({1.0, -1.0}) + + def test_mfe_gte_mae(self): + """MFE (best) must always be >= MAE (worst) within the trade.""" + pos, fp, h, l = self._run_ohlcv(n=200) + _, _, _, _, _, _, _, mae, mfe = extract_trades(pos, fp, h, l) + if len(mae) > 0: + assert np.all(mfe >= mae) + + def test_raises_on_mismatched_lengths(self): + pos, fp, h, l = self._run_ohlcv() + with pytest.raises(Exception): + extract_trades(pos[:-1], fp, h, l) + + +# =========================================================================== +# Group 4: backtest_multi_asset_core +# =========================================================================== + + +class TestBacktestMultiAssetCore: + def test_single_asset_matches_backtest_core(self): + """1-asset multi_asset == scalar backtest_core with same weights.""" + rng = np.random.default_rng(99) + n = 150 + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + signals = np.where(np.arange(n) % 15 < 7, 1.0, -1.0).astype(np.float64) + + # Single asset via multi_asset (weights = signals) + close2d = close.reshape(n, 1) + w2d = signals.reshape(n, 1) + ar, pr, pe = backtest_multi_asset_core(close2d, w2d) + + # Same via backtest_core + _, _, sr_ref, eq_ref = backtest_core(close, signals) + + npt.assert_allclose(pe, np.asarray(eq_ref), rtol=1e-6) + + def test_returns_shapes(self): + n, k = 100, 5 + rng = np.random.default_rng(0) + c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100 + w2d = np.ones((n, k)) * 0.2 + ar, pr, pe = backtest_multi_asset_core(c2d, w2d) + assert ar.shape == (n, k) + assert pr.shape == (n,) + assert pe.shape == (n,) + + def test_parallel_equals_serial(self): + n, k = 120, 4 + rng = np.random.default_rng(1) + c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100 + w2d = rng.choice([-1.0, 0.0, 1.0], size=(n, k)).astype(np.float64) + _, _, pe_par = backtest_multi_asset_core(c2d, w2d, parallel=True) + _, _, pe_ser = backtest_multi_asset_core(c2d, w2d, parallel=False) + npt.assert_allclose(pe_par, pe_ser, rtol=1e-10) + + def test_raises_on_mismatched_shapes(self): + c2d = np.ones((50, 3)) + w2d = np.ones((50, 4)) # wrong n_assets + with pytest.raises(Exception): + backtest_multi_asset_core(c2d, w2d) + + def test_equity_starts_at_one(self): + n, k = 50, 2 + c2d = np.ones((n, k)) * 100.0 + w2d = np.zeros((n, k)) + _, _, pe = backtest_multi_asset_core(c2d, w2d) + assert pe[0] == pytest.approx(1.0, abs=1e-9) + + +# =========================================================================== +# Group 5: monte_carlo_bootstrap +# =========================================================================== + + +class TestMonteCarloBootstrap: + def _returns(self, n: int = 200, seed: int = 5): + rng = np.random.default_rng(seed) + return rng.standard_normal(n) * 0.01 + + def test_output_shape(self): + r = self._returns() + mc = monte_carlo_bootstrap(r, n_sims=50) + assert mc.shape == (50, 200) + + def test_seed_reproducibility(self): + r = self._returns() + mc1 = monte_carlo_bootstrap(r, n_sims=100, seed=7) + mc2 = monte_carlo_bootstrap(r, n_sims=100, seed=7) + npt.assert_array_equal(mc1, mc2) + + def test_different_seeds_differ(self): + r = self._returns() + mc1 = monte_carlo_bootstrap(r, n_sims=50, seed=1) + mc2 = monte_carlo_bootstrap(r, n_sims=50, seed=2) + assert not np.allclose(mc1, mc2) + + def test_equity_starts_at_one(self): + r = self._returns() + mc = monte_carlo_bootstrap(r, n_sims=20) + # Bootstrap resamples returns randomly, so mc[:,0] = 1 + random_return + # All first-bar equity values must be in range of possible (1+r) values + possible_first_bar = set(np.round(1.0 + r, 12)) + for val in mc[:, 0]: + assert any(abs(val - p) < 1e-9 for p in possible_first_bar) + + def test_block_bootstrap_shape(self): + r = self._returns(n=100) + mc = monte_carlo_bootstrap(r, n_sims=30, block_size=5) + assert mc.shape == (30, 100) + + def test_raises_on_empty_input(self): + with pytest.raises(Exception): + monte_carlo_bootstrap(np.array([0.01]), n_sims=10) + + +# =========================================================================== +# Group 6: walk_forward_indices +# =========================================================================== + + +class TestWalkForwardIndices: + def test_output_shape(self): + idx = walk_forward_indices(500, 200, 50) + assert idx.ndim == 2 + assert idx.shape[1] == 4 + + def test_non_anchored_fixed_train_window(self): + idx = walk_forward_indices(400, 200, 50) + n_folds = idx.shape[0] + assert n_folds >= 2 + for fold in idx: + tr_len = fold[1] - fold[0] + assert tr_len == 200 + + def test_anchored_growing_train_window(self): + idx = walk_forward_indices(400, 150, 50, anchored=True) + for fold in idx: + assert fold[0] == 0 # always starts at 0 + train_lengths = idx[:, 1] - idx[:, 0] + assert train_lengths[-1] >= train_lengths[0] + + def test_no_test_fold_overlap(self): + idx = walk_forward_indices(500, 200, 50) + # Test intervals should be non-overlapping (step = test_bars by default) + for i in range(len(idx) - 1): + assert idx[i, 3] <= idx[i + 1, 2] + + def test_all_test_folds_within_bounds(self): + n = 600 + idx = walk_forward_indices(n, 200, 100) + assert np.all(idx[:, 0] >= 0) + assert np.all(idx[:, 3] <= n) + + def test_step_bars_parameter(self): + idx_default = walk_forward_indices(500, 200, 50) + idx_step = walk_forward_indices(500, 200, 50, step_bars=25) + # Smaller step → more folds + assert idx_step.shape[0] >= idx_default.shape[0] + + def test_raises_when_no_folds_fit(self): + with pytest.raises(Exception): + walk_forward_indices(100, 80, 80) # 80+80 > 100 + + +# =========================================================================== +# Group 7: kelly_fraction / half_kelly_fraction +# =========================================================================== + + +class TestKellyFraction: + def test_positive_expectancy(self): + k = kelly_fraction(0.6, 0.02, 0.01) + assert k > 0.0 + + def test_zero_edge_returns_zero(self): + """win_rate = loss_rate AND avg_win = avg_loss → Kelly = 0.""" + k = kelly_fraction(0.5, 0.01, 0.01) + assert k == pytest.approx(0.0, abs=1e-9) + + def test_negative_expectancy_clamped_to_zero(self): + k = kelly_fraction(0.3, 0.01, 0.02) + assert k == 0.0 + + def test_half_kelly_is_half_of_kelly(self): + k = kelly_fraction(0.6, 0.03, 0.015) + hk = half_kelly_fraction(0.6, 0.03, 0.015) + assert hk == pytest.approx(k / 2.0, rel=1e-9) + + def test_result_clamped_to_one(self): + k = kelly_fraction(0.99, 0.5, 0.001) + assert k <= 1.0 + + def test_raises_on_invalid_win_rate(self): + with pytest.raises(Exception): + kelly_fraction(1.5, 0.01, 0.01) + + def test_raises_on_nonpositive_avg_win(self): + with pytest.raises(Exception): + kelly_fraction(0.6, 0.0, 0.01) + + +# =========================================================================== +# Group 8: BacktestEngine (Python API) +# =========================================================================== + + +class TestBacktestEngine: + def _close(self, n: int = 200, seed: int = 10) -> np.ndarray: + rng = np.random.default_rng(seed) + return np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + + def test_run_returns_advanced_result(self): + c = self._close() + r = BacktestEngine().run(c, "rsi_30_70") + assert isinstance(r, AdvancedBacktestResult) + + def test_advanced_result_is_backtest_result(self): + c = self._close() + r = BacktestEngine().run(c, "rsi_30_70") + assert isinstance(r, BacktestResult) + + def test_chaining_returns_self(self): + engine = BacktestEngine() + assert engine.with_commission(0.001) is engine + assert engine.with_slippage(5.0) is engine + assert engine.with_stop_loss(0.02) is engine + + def test_all_metric_keys_present(self): + c = self._close() + r = BacktestEngine().run(c, "rsi_30_70") + assert "sharpe" in r.metrics + assert "max_drawdown" in r.metrics + assert "cagr" in r.metrics + + def test_drawdown_series_shape(self): + c = self._close() + r = BacktestEngine().run(c) + assert r.drawdown_series.shape == c.shape + + def test_drawdown_series_nonpositive(self): + c = self._close() + r = BacktestEngine().run(c) + assert np.all(r.drawdown_series <= 0.0) + + def test_engine_close_only_matches_backtest_func(self): + c = self._close() + r_engine = BacktestEngine().run(c, "rsi_30_70") + r_func = backtest(c, strategy="rsi_30_70") + npt.assert_allclose(r_engine.equity, r_func.equity, rtol=1e-9) + + def test_ohlcv_mode_runs(self): + c = self._close() + h = c * 1.01 + l = c * 0.99 + o = c * 0.999 + r = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_stop_loss(0.02) + .run(c) + ) + assert r.equity.shape == c.shape + + def test_trades_dataframe_columns(self): + c = self._close() + r = BacktestEngine().run(c, "sma_crossover") + if r.trades is not None: + expected_cols = { + "entry_bar", + "exit_bar", + "direction", + "entry_price", + "exit_price", + "pnl_pct", + "duration_bars", + "mae", + "mfe", + } + assert expected_cols.issubset(set(r.trades.columns)) + + def test_invalid_fill_mode_raises(self): + with pytest.raises(Exception): + BacktestEngine().with_fill_mode("invalid") + + +# =========================================================================== +# Group 9: walk_forward() Python API +# =========================================================================== + + +class TestWalkForward: + def _setup(self, n: int = 400): + rng = np.random.default_rng(99) + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + param_grid = [{"timeperiod": p} for p in [10, 14, 20]] + return close, param_grid + + def test_returns_walk_forward_result(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + assert isinstance(r, WalkForwardResult) + + def test_fold_count_matches_indices(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + assert len(r.fold_results) == r.fold_indices.shape[0] + + def test_oos_equity_length(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + total_test_bars = sum( + int(r.fold_indices[i, 3]) - int(r.fold_indices[i, 2]) + for i in range(len(r.fold_results)) + ) + assert len(r.oos_equity) == total_test_bars + + def test_oos_metrics_has_sharpe(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + assert "sharpe" in r.oos_metrics + + def test_anchored_mode(self): + c, pg = self._setup() + r = walk_forward( + c, rsi_strategy, pg, train_bars=200, test_bars=50, anchored=True + ) + # In anchored mode, training always starts at 0 + assert np.all(r.fold_indices[:, 0] == 0) + + def test_param_stability_populated(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + assert "timeperiod" in r.param_stability + assert "most_chosen" in r.param_stability["timeperiod"] + + +# =========================================================================== +# Group 10: monte_carlo() Python API +# =========================================================================== + + +class TestMonteCarlo: + def _result(self, n: int = 200): + rng = np.random.default_rng(77) + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + return BacktestEngine().run(c, "rsi_30_70") + + def test_returns_monte_carlo_result(self): + r = self._result() + mc = monte_carlo(r, n_sims=100) + assert isinstance(mc, MonteCarloResult) + + def test_equity_curves_shape(self): + r = self._result(n=150) + mc = monte_carlo(r, n_sims=80) + assert mc.equity_curves.shape == (80, 150) + + def test_confidence_bounds_cover_median(self): + r = self._result() + mc = monte_carlo(r, n_sims=500, confidence=0.95) + assert np.all(mc.confidence_lower <= mc.median_curve + 1e-9) + assert np.all(mc.confidence_upper >= mc.median_curve - 1e-9) + + def test_prob_profit_in_range(self): + r = self._result() + mc = monte_carlo(r, n_sims=200) + assert 0.0 <= mc.prob_profit <= 1.0 + + def test_accepts_raw_array(self): + rng = np.random.default_rng(3) + returns = rng.standard_normal(100) * 0.01 + mc = monte_carlo(returns, n_sims=50) + assert isinstance(mc, MonteCarloResult) + + def test_seed_reproducibility(self): + r = self._result() + mc1 = monte_carlo(r, n_sims=50, seed=1) + mc2 = monte_carlo(r, n_sims=50, seed=1) + npt.assert_array_equal(mc1.equity_curves, mc2.equity_curves) + + def test_var_is_low_percentile_of_terminal_equity(self): + r = self._result() + mc = monte_carlo(r, n_sims=1000, confidence=0.95) + # VaR = 5th percentile of terminal equity + expected_var = float(np.percentile(mc.terminal_equity, 5.0)) + assert mc.var == pytest.approx(expected_var, rel=1e-6) + + +# =========================================================================== +# Backward compatibility guard +# =========================================================================== + + +class TestBackwardCompat: + def test_backtest_still_returns_backtest_result(self): + rng = np.random.default_rng(0) + c = np.cumprod(1 + rng.standard_normal(100) * 0.01) * 100.0 + r = backtest(c, strategy="rsi_30_70") + assert type(r) is BacktestResult + + def test_portfolio_backtest_result(self): + rng = np.random.default_rng(0) + n, k = 100, 3 + c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100.0 + w2d = np.ones((n, k)) / k + r = backtest_portfolio(c2d, w2d) + assert isinstance(r, PortfolioBacktestResult) + assert r.portfolio_equity.shape == (n,) + + +# =========================================================================== +# Sprint 1: Limit orders, time-based exit, pct_range slippage +# =========================================================================== + + +class TestLimitOrders: + """Tests for limit-price order fill logic in backtest_ohlcv_core.""" + + def _ohlcv(self): + n = 50 + rng = np.random.default_rng(7) + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.003, n)) + high = close * (1 + rng.uniform(0.002, 0.008, n)) + low = close * (1 - rng.uniform(0.002, 0.008, n)) + return open_, high, low, close, n + + def test_limit_nan_behaves_like_market(self): + """NaN limit prices should give identical results to no limit array.""" + o, h, l, c, n = self._ohlcv() + signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64) + lp_nan = np.full(n, np.nan) + + pos_mkt, fp_mkt, _, sr_mkt, eq_mkt = backtest_ohlcv_core(o, h, l, c, signals) + pos_lim, fp_lim, _, sr_lim, eq_lim = backtest_ohlcv_core( + o, h, l, c, signals, limit_prices=lp_nan + ) + npt.assert_array_almost_equal(pos_mkt, pos_lim) + npt.assert_array_almost_equal(sr_mkt, sr_lim) + npt.assert_array_almost_equal(eq_mkt, eq_lim) + + def test_buy_limit_fills_at_limit_price(self): + """Buy limit fills when low <= limit_price and uses limit as fill price.""" + n = 10 + close = np.full(n, 100.0) + open_ = np.full(n, 100.0) + high = np.full(n, 102.0) + low = np.full(n, 98.0) + # Buy signal at bar 0, limit price 99 — low=98 <= 99 so should fill + signals = np.zeros(n) + signals[0] = 1.0 # want to go long at bar 1 + limit_prices = np.full(n, np.nan) + limit_prices[0] = 99.0 # limit for bar 1 execution + + _, fp, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + fill_mode="market_close", + limit_prices=limit_prices, + ) + # Bar 1 should have a fill at 99.0 (the limit price) + assert fp[1] == pytest.approx(99.0, rel=1e-6) + + def test_buy_limit_not_hit_no_fill(self): + """Buy limit is not filled when low > limit_price.""" + n = 10 + close = np.full(n, 100.0) + open_ = np.full(n, 100.0) + high = np.full(n, 102.0) + low = np.full(n, 98.0) # low=98 + signals = np.zeros(n) + signals[0] = 1.0 # go long at bar 1 + limit_prices = np.full(n, np.nan) + limit_prices[0] = 97.0 # limit=97, but low=98 > 97 → no fill + + pos, fp, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + fill_mode="market_close", + limit_prices=limit_prices, + ) + # Position should stay 0 at bar 1 (limit not hit) + assert pos[1] == pytest.approx(0.0) + assert np.isnan(fp[1]) + + def test_sell_limit_fills_when_high_hits(self): + """Sell limit fills when high >= limit_price.""" + n = 10 + close = np.full(n, 100.0) + open_ = np.full(n, 100.0) + high = np.full(n, 103.0) + low = np.full(n, 97.0) + signals = np.zeros(n) + signals[0] = -1.0 # go short at bar 1 + limit_prices = np.full(n, np.nan) + limit_prices[0] = 102.0 # high=103 >= 102 → fill + + _, fp, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + fill_mode="market_close", + limit_prices=limit_prices, + ) + assert fp[1] == pytest.approx(102.0, rel=1e-6) + + def test_engine_with_limit_orders(self): + """BacktestEngine.with_limit_orders with NaN limits matches market orders.""" + o, h, l, c, n = self._ohlcv() + # NaN limit prices = market orders; result must match engine without limit array + limit_prices = np.full(n, np.nan) + + result_mkt = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .run(c, strategy="sma_crossover", fast=5, slow=20) + ) + result_lim = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_limit_orders(limit_prices) + .run(c, strategy="sma_crossover", fast=5, slow=20) + ) + assert isinstance(result_lim, AdvancedBacktestResult) + npt.assert_array_almost_equal(result_mkt.equity, result_lim.equity) + + +class TestMaxHold: + """Tests for time-based exit (max_hold_bars).""" + + def _flat_ohlcv(self, n=30): + close = np.ones(n) * 100.0 + open_ = close.copy() + high = close * 1.005 + low = close * 0.995 + signals = np.ones(n) # always long signal + return open_, high, low, close, signals + + def test_position_exits_after_n_bars(self): + """Position should be closed after max_hold_bars regardless of signal.""" + o, h, l, c, s = self._flat_ohlcv(n=20) + max_hold = 5 + pos, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s, max_hold_bars=max_hold) + + # Find first entry + entry_bar = None + for i in range(len(pos)): + if pos[i] != 0.0: + entry_bar = i + break + + assert entry_bar is not None + # Position should be 0 at entry_bar + max_hold + exit_bar = entry_bar + max_hold + if exit_bar < len(pos): + assert pos[exit_bar] == pytest.approx(0.0), ( + f"Expected exit at bar {exit_bar}, pos={pos[exit_bar]}" + ) + + def test_max_hold_zero_is_disabled(self): + """max_hold_bars=0 should not affect behaviour (disabled).""" + o, h, l, c, s = self._flat_ohlcv(n=20) + pos_no_hold, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s) + pos_hold_0, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s, max_hold_bars=0) + npt.assert_array_almost_equal(pos_no_hold, pos_hold_0) + + def test_engine_with_max_hold(self): + """BacktestEngine.with_max_hold integrates correctly.""" + rng = np.random.default_rng(99) + n = 100 + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + h = c * 1.01 + l = c * 0.99 + o = c * 1.001 + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_max_hold(5) + .run(c, strategy="rsi_30_70") + ) + assert isinstance(result, AdvancedBacktestResult) + + def test_max_hold_stop_takes_priority(self): + """A stop-loss that triggers before max_hold should exit early.""" + n = 20 + close = np.array([100.0] * 5 + [95.0] * 15) # price drops on bar 5 + open_ = close.copy() + high = close * 1.002 + low = np.array([100.0] * 5 + [93.0] * 15) # low hits stop at bar 5 + signals = np.ones(n) # always long + + pos_sl, _, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.05, + max_hold_bars=10, + ) + pos_hold, _, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.05, + ) + # Both should exit around the same time (stop triggers before hold limit) + # At least the stop-loss exit should happen — position goes to 0 before bar 10+1 + assert any(pos_sl[5:11] == 0.0), ( + "Stop-loss should have triggered before max_hold" + ) + + +class TestSlippagePctRange: + """Tests for pct_range slippage mode.""" + + def _ohlcv_wide_range(self, n=20): + """OHLCV with a wide bar range to make pct_range slippage measurable.""" + close = np.full(n, 100.0) + open_ = np.full(n, 100.0) + high = np.full(n, 110.0) # range = 10 (10%) + low = np.full(n, 90.0) + signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64) + return open_, high, low, close, signals + + def test_pct_range_more_costly_than_zero_slippage(self): + """With wide bar range, pct_range slip should reduce final equity vs no slip.""" + o, h, l, c, s = self._ohlcv_wide_range() + _, _, _, _, eq_no_slip = backtest_ohlcv_core(o, h, l, c, s) + _, _, _, _, eq_pct = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.10) + # pct_range slippage = 0.10 × (110-90)/100 = 0.02 = 200bps per trade + assert eq_pct[-1] < eq_no_slip[-1] + + def test_pct_range_more_costly_than_bps_equivalent(self): + """pct_range with wide range should be costlier than modest bps slip.""" + o, h, l, c, s = self._ohlcv_wide_range() + # bps slip: 5bps = 0.05% of fill, small + _, _, _, _, eq_bps = backtest_ohlcv_core(o, h, l, c, s, slippage_bps=5.0) + # pct_range: 10% of 20-wide range = 2.0 absolute, or 2% of close=100 + _, _, _, _, eq_pct = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.10) + assert eq_pct[-1] < eq_bps[-1] + + def test_pct_range_zero_equals_no_slippage(self): + """slippage_pct_range=0 should give same result as no slippage.""" + o, h, l, c, s = self._ohlcv_wide_range() + _, _, _, _, eq_base = backtest_ohlcv_core(o, h, l, c, s) + _, _, _, _, eq_zero = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.0) + npt.assert_array_almost_equal(eq_base, eq_zero) + + def test_engine_with_slippage_pct_range(self): + """BacktestEngine.with_slippage_pct_range integrates correctly.""" + rng = np.random.default_rng(17) + n = 80 + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + h = c * 1.01 + l = c * 0.99 + o = c * 1.001 + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_slippage_pct_range(0.05) + .run(c, strategy="sma_crossover", fast=5, slow=20) + ) + assert isinstance(result, AdvancedBacktestResult) + + +# =========================================================================== +# Group 11: Phase 1 Features (spread_bps, breakeven_stop, bracket order priority) +# =========================================================================== + + +from ferro_ta._ferro_ta import CommissionModel as RustCommissionModel + + +class TestPhase1Features: + """Tests for Phase 1 features: spread_bps, breakeven_pct, bracket order priority.""" + + def test_spread_bps_increases_cost(self): + """CommissionModel with spread_bps=10 should produce lower equity than spread_bps=0.""" + rng = np.random.default_rng(99) + n = 200 + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + h = c * 1.005 + l = c * 0.995 + o = c * 0.999 + signals = np.where(np.arange(n) % 20 < 10, 1.0, 0.0).astype(np.float64) + + # Build a commission model with spread_bps=0 + cm_no_spread = RustCommissionModel() + cm_no_spread.spread_bps = 0.0 + + # Build a commission model with spread_bps=10 + cm_with_spread = RustCommissionModel() + cm_with_spread.spread_bps = 10.0 + + _, _, _, _, eq_no_spread = backtest_ohlcv_core( + o, h, l, c, signals, commission=cm_no_spread + ) + _, _, _, _, eq_with_spread = backtest_ohlcv_core( + o, h, l, c, signals, commission=cm_with_spread + ) + + # Spread adds cost on each trade leg → should produce lower or equal final equity + assert eq_with_spread[-1] <= eq_no_spread[-1], ( + f"spread equity {eq_with_spread[-1]:.6f} should be <= no-spread equity {eq_no_spread[-1]:.6f}" + ) + + def test_spread_bps_getter_setter(self): + """CommissionModel spread_bps getter/setter round-trip works correctly.""" + m = RustCommissionModel() + assert m.spread_bps == 0.0 + m.spread_bps = 5.0 + assert m.spread_bps == pytest.approx(5.0) + + def test_spread_bps_total_cost(self): + """CommissionModel.total_cost includes spread cost at correct magnitude.""" + m = RustCommissionModel() + m.spread_bps = 20.0 # 20 bps total round-trip = 10 bps each leg + trade_value = 100_000.0 + cost = m.total_cost(trade_value, 1.0, True) + # Expected: 10 bps = 0.001 * 100_000 = 100 per leg + assert cost == pytest.approx(100.0, rel=1e-6) + + def test_breakeven_stop_prevents_loss(self): + """With breakeven_pct=0.02, after price rises 3% then falls, exit should be near entry.""" + # Build synthetic data: entry at bar 1, then price rises 3%, then falls below entry + # Bar layout: [100, 103, 103, 101, 99, 99, 99, 99, 99] + # We want a long signal from bar 0 onwards + n = 20 + # Create price data: starts at 100, rises to 103 at bar 3, then drops to 97 + close = np.array([100.0] * 3 + [103.0] * 3 + [97.0] * (n - 6), dtype=np.float64) + open_ = close.copy() + high = close * 1.002 + low = close * 0.998 + # Set high of bar 3 to clearly trigger breakeven (>= 103 = entry * 1.03) + # entry happens at bar 1 (open of bar 1 = 100), so entry_price ≈ 100 + # breakeven triggers when h >= 100 * 1.02 = 102 → triggers at bar 3 (close=103, high≥103) + high[3] = 103.5 # clearly above 102 (entry * 1.02) + # At bar 6, low drops below entry (100), breakeven stop should trigger + low[6] = 99.0 # below entry price 100 → breakeven stop fires + signals = np.ones(n, dtype=np.float64) # always long + + _, fp, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.0, + breakeven_pct=0.02, + ) + # Find first non-NaN fill price after the entry bar (entry at bar 1) + # Breakeven exit should happen at or near entry price (100), not at a big loss + exit_fps = fp[~np.isnan(fp)] + # The breakeven stop exit should be at entry_price (~100), not at 97 or lower + # Entry fill is at open of bar 1 = 100.0 + # After breakeven activates, stop = entry (~100). So exit fill should be ~100 + assert len(exit_fps) >= 1 + # The exit fill from breakeven should be close to entry price (within 1%) + # (first fill = entry, subsequent fills = exits) + if len(exit_fps) >= 2: + breakeven_exit = exit_fps[1] + assert breakeven_exit >= 99.0, ( + f"breakeven exit {breakeven_exit} should be >= 99 (near entry 100)" + ) + + def test_bracket_order_tp_fires_before_sl(self): + """When both SL and TP are breached in same bar, and open is near TP → TP fires.""" + # Long trade: entry at price 100 + # Bar where both trigger: open=109 (very close to TP=110), high=112, low=90 + # SL = 100*(1-0.10) = 90, TP = 100*(1+0.10) = 110 + # open=109 is closer to TP=110 (dist=1) than to SL=90 (dist=19) → TP fires + entry_price = 100.0 + close = np.array( + [entry_price, entry_price, entry_price, 108.0, 108.0], dtype=np.float64 + ) + open_ = np.array( + [entry_price, entry_price, entry_price, 109.0, 108.0], dtype=np.float64 + ) + high = np.array( + [entry_price, entry_price, entry_price, 112.0, 108.0], dtype=np.float64 + ) + low = np.array( + [entry_price, entry_price, entry_price, 88.0, 108.0], dtype=np.float64 + ) + signals = np.array([0.0, 1.0, 1.0, 1.0, 0.0], dtype=np.float64) + + _, fp, _, sr, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.10, + take_profit_pct=0.10, + ) + # Bar 3 is where both trigger. TP=110. SL=90. Open=109 → TP fires. + # fill price at bar 3 should be ~110 (TP), not 90 (SL) + assert not np.isnan(fp[3]), "Expected a fill at bar 3" + tp_level = entry_price * 1.10 # 110 + assert fp[3] == pytest.approx(tp_level, rel=1e-6), ( + f"Expected TP fill at ~{tp_level}, got {fp[3]}" + ) + + def test_bracket_order_sl_fires_before_tp(self): + """When both SL and TP are breached in same bar, and open is near SL → SL fires.""" + # Long trade: entry at 100 + # Bar where both trigger: open=91 (very close to SL=90), high=112, low=88 + # SL=90, TP=110. open=91 is closer to SL=90 (dist=1) than to TP=110 (dist=19) → SL fires + entry_price = 100.0 + close = np.array( + [entry_price, entry_price, entry_price, 95.0, 95.0], dtype=np.float64 + ) + open_ = np.array( + [entry_price, entry_price, entry_price, 91.0, 95.0], dtype=np.float64 + ) + high = np.array( + [entry_price, entry_price, entry_price, 112.0, 95.0], dtype=np.float64 + ) + low = np.array( + [entry_price, entry_price, entry_price, 88.0, 95.0], dtype=np.float64 + ) + signals = np.array([0.0, 1.0, 1.0, 1.0, 0.0], dtype=np.float64) + + _, fp, _, sr, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.10, + take_profit_pct=0.10, + ) + # Bar 3: both SL(90) and TP(110) are triggered. open=91 is close to SL → SL fires. + assert not np.isnan(fp[3]), "Expected a fill at bar 3" + sl_level = entry_price * 0.90 # 90 + assert fp[3] == pytest.approx(sl_level, rel=1e-6), ( + f"Expected SL fill at ~{sl_level}, got {fp[3]}" + ) + + def test_breakeven_engine_integration(self): + """BacktestEngine.with_breakeven_stop integrates correctly.""" + rng = np.random.default_rng(77) + n = 150 + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + h = c * 1.01 + l = c * 0.99 + o = c * 0.999 + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_breakeven_stop(0.02) + .run(c, strategy="sma_crossover", fast=5, slow=20) + ) + assert isinstance(result, AdvancedBacktestResult) + assert np.all(np.isfinite(result.equity)) + + +# =========================================================================== +# Phase 2: Portfolio & Risk Features +# =========================================================================== + + +class TestPhase2Features: + """Tests for Phase 2: short borrow cost, margin/leverage, circuit breakers, + and portfolio constraints.""" + + # ----------------------------------------------------------------------- + # Helper: synthetic OHLCV with controllable direction + # ----------------------------------------------------------------------- + + def _make_short_ohlcv(self, n: int = 100, seed: int = 7) -> tuple: + """Produce OHLCV where the price trends downward (good for shorts).""" + rng = np.random.default_rng(seed) + # Steady downtrend + close = 100.0 * np.cumprod(1 - np.abs(rng.standard_normal(n)) * 0.005) + open_ = close * (1 + rng.uniform(-0.002, 0.002, n)) + high = np.maximum(open_, close) * (1 + rng.uniform(0, 0.003, n)) + low = np.minimum(open_, close) * (1 - rng.uniform(0, 0.003, n)) + # Always short + signals = np.full(n, -1.0, dtype=np.float64) + return open_, high, low, close, signals + + # ----------------------------------------------------------------------- + # 1. Short borrow cost + # ----------------------------------------------------------------------- + + def test_short_borrow_cost_reduces_equity(self): + """Short position with short_borrow_rate_annual=0.10 should produce lower + final equity than the same run with no borrow cost.""" + from ferro_ta._ferro_ta import CommissionModel + + o, h, l, c, signals = self._make_short_ohlcv(n=252) + + # Commission model without borrow cost + cm_no_borrow = CommissionModel() + + # Commission model with 10% annual borrow cost + cm_with_borrow = CommissionModel() + cm_with_borrow.short_borrow_rate_annual = 0.10 + + _, _, _, _, eq_no_borrow = backtest_ohlcv_core( + o, h, l, c, signals, commission=cm_no_borrow + ) + _, _, _, _, eq_with_borrow = backtest_ohlcv_core( + o, h, l, c, signals, commission=cm_with_borrow + ) + + # With borrow cost, final equity must be strictly lower + assert float(eq_with_borrow[-1]) < float(eq_no_borrow[-1]), ( + f"Expected borrow-cost equity {eq_with_borrow[-1]:.6f} < " + f"no-borrow equity {eq_no_borrow[-1]:.6f}" + ) + + def test_short_borrow_cost_getter_setter(self): + """CommissionModel.short_borrow_rate_annual getter/setter works.""" + from ferro_ta._ferro_ta import CommissionModel + + cm = CommissionModel() + assert cm.short_borrow_rate_annual == pytest.approx(0.0) + cm.short_borrow_rate_annual = 0.05 + assert cm.short_borrow_rate_annual == pytest.approx(0.05) + + def test_short_borrow_zero_rate_no_effect(self): + """With short_borrow_rate_annual=0, borrow cost should not affect equity.""" + from ferro_ta._ferro_ta import CommissionModel + + o, h, l, c, signals = self._make_short_ohlcv(n=50) + cm_zero = CommissionModel() + cm_zero.short_borrow_rate_annual = 0.0 + + _, _, _, sr1, eq1 = backtest_ohlcv_core(o, h, l, c, signals) + _, _, _, sr2, eq2 = backtest_ohlcv_core(o, h, l, c, signals, commission=cm_zero) + + npt.assert_allclose(eq1, eq2, rtol=1e-10) + + def test_short_borrow_engine_integration(self): + """BacktestEngine with commission model including short_borrow_rate_annual runs.""" + from ferro_ta._ferro_ta import CommissionModel + + o, h, l, c, sigs = self._make_short_ohlcv(n=80) + cm = CommissionModel() + cm.short_borrow_rate_annual = 0.08 + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_commission_model(cm) + .run(c, lambda x: np.full(len(x), -1.0)) + ) + assert isinstance(result, AdvancedBacktestResult) + assert np.all(np.isfinite(result.equity)) + + # ----------------------------------------------------------------------- + # 2. Margin call force-close + # ----------------------------------------------------------------------- + + def test_margin_call_force_closes_position(self): + """A declining price sequence triggers a margin call and force-closes the long.""" + n = 20 + # Price drops sharply — enough to trigger a margin call on a long + open_ = np.ones(n) * 100.0 + high = np.ones(n) * 101.0 + low = np.ones(n) * 99.0 + close = np.ones(n) * 100.0 + + # After bar 5, price tanks sharply every bar + for i in range(5, n): + drop = 0.30 # 30% per bar — guaranteed to exceed margin + open_[i] = open_[i - 1] * (1 - drop) + high[i] = open_[i] * 1.001 + low[i] = open_[i] * 0.999 + close[i] = open_[i] + + # Always long + signals = np.ones(n, dtype=np.float64) + + # margin_ratio=0.2 means 20% margin (5x leverage) + # margin_call_pct=0.5 means call when equity hits 50% of initial margin + _, _, _, sr_margin, eq_margin = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + margin_ratio=0.2, + margin_call_pct=0.5, + ) + _, _, _, sr_no_margin, eq_no_margin = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + ) + + # Margin call should cause a forced exit, resulting in different equity + # (the margin version stops losses earlier) + assert not np.allclose(eq_margin, eq_no_margin), ( + "Expected margin call to alter equity curve" + ) + + def test_margin_disabled_when_ratio_zero(self): + """margin_ratio=0 should behave identically to not passing the parameter.""" + o, h, l, c, signals = _make_ohlcv(n=80) + + _, _, _, _, eq_default = backtest_ohlcv_core(o, h, l, c, signals) + _, _, _, _, eq_zero_margin = backtest_ohlcv_core( + o, h, l, c, signals, margin_ratio=0.0 + ) + + npt.assert_allclose(eq_default, eq_zero_margin, rtol=1e-10) + + def test_margin_engine_builder(self): + """BacktestEngine.with_leverage builder sets parameters without error.""" + o, h, l, c, _ = _make_ohlcv(n=60) + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_leverage(margin_ratio=0.2, margin_call_pct=0.5) + .run(c, lambda x: np.ones(len(x))) + ) + assert isinstance(result, AdvancedBacktestResult) + assert np.all(np.isfinite(result.equity)) + + # ----------------------------------------------------------------------- + # 3. Total loss limit (circuit breaker) + # ----------------------------------------------------------------------- + + def test_total_loss_limit_halts_trading(self): + """total_loss_limit=0.10 should halt trading once equity drops 10%.""" + n = 100 + # Construct a losing price sequence: steady decline + close = 100.0 * np.cumprod(np.full(n, 0.99)) # -1% per bar + open_ = close * 1.001 + high = close * 1.005 + low = close * 0.995 + + # Always long (so position loses money as price falls) + signals = np.ones(n, dtype=np.float64) + + pos_with_limit, _, _, _, eq_with_limit = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + total_loss_limit=0.10, + ) + pos_no_limit, _, _, _, eq_no_limit = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + ) + + # After circuit break the position should be 0 + # Check that at some point positions go to 0 in the limited version + # while the unlimited version stays long + assert np.any(pos_with_limit == 0.0), ( + "Expected some bars with no position after circuit break" + ) + # Unlimited version should stay long throughout (except bar 0) + assert np.all(pos_no_limit[1:] == 1.0), "No-limit should stay long" + + def test_total_loss_limit_does_not_trip_with_no_loss(self): + """total_loss_limit does not trip on a profitable sequence.""" + n = 60 + close = 100.0 * np.cumprod(np.full(n, 1.005)) # +0.5% per bar + open_ = close * 0.999 + high = close * 1.003 + low = close * 0.997 + signals = np.ones(n, dtype=np.float64) + + pos, _, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + total_loss_limit=0.20, + ) + # No circuit break should fire; position stays long + assert np.all(pos[1:] == 1.0) + + # ----------------------------------------------------------------------- + # 4. Daily (per-bar) loss limit circuit breaker + # ----------------------------------------------------------------------- + + def test_daily_loss_limit_halts_after_large_bar_loss(self): + """A single large losing bar triggers the daily_loss_limit circuit breaker.""" + n = 30 + close = np.ones(n) * 100.0 + open_ = np.ones(n) * 100.0 + high = np.ones(n) * 101.0 + low = np.ones(n) * 99.0 + + # Create one very large losing bar at bar 10 (price drops 15%) + # Strategy is long, so this is a large loss + crash_bar = 10 + close[crash_bar] = close[crash_bar - 1] * 0.85 + open_[crash_bar] = close[crash_bar - 1] * 0.86 + high[crash_bar] = open_[crash_bar] * 1.001 + low[crash_bar] = close[crash_bar] * 0.999 + + signals = np.ones(n, dtype=np.float64) + + pos, _, _, sr, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + daily_loss_limit=0.05, # 5% per-bar loss limit + ) + + # After the crash bar, circuit breaker should fire and position should go to 0 + # Check bars after crash_bar+1 have position 0 + assert np.any(pos[crash_bar + 1 :] == 0.0), ( + "Expected circuit breaker to zero out position after crash bar" + ) + + def test_daily_loss_limit_zero_is_disabled(self): + """daily_loss_limit=0 (default) should not change behavior.""" + o, h, l, c, signals = _make_ohlcv(n=80) + + _, _, _, _, eq_default = backtest_ohlcv_core(o, h, l, c, signals) + _, _, _, _, eq_zero_limit = backtest_ohlcv_core( + o, h, l, c, signals, daily_loss_limit=0.0 + ) + + npt.assert_allclose(eq_default, eq_zero_limit, rtol=1e-10) + + def test_loss_limits_engine_builder(self): + """BacktestEngine.with_loss_limits builder sets parameters.""" + o, h, l, c, _ = _make_ohlcv(n=80) + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_loss_limits(daily=0.05, total=0.20) + .run(c, strategy="sma_crossover") + ) + assert isinstance(result, AdvancedBacktestResult) + assert np.all(np.isfinite(result.equity)) + + # ----------------------------------------------------------------------- + # 5. Portfolio constraints + # ----------------------------------------------------------------------- + + def test_portfolio_max_asset_weight_clamps_signal(self): + """max_asset_weight=0.5 should clamp signals from ±1 to ±0.5.""" + rng = np.random.default_rng(99) + n_bars, n_assets = 100, 3 + close_2d = ( + np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0) + * 100.0 + ) + # Alternating ±1 signals — shape (n_bars, n_assets) + row_flags = (np.arange(n_bars) % 10 < 5)[:, None] # (n, 1) + weights_2d = np.where(np.tile(row_flags, (1, n_assets)), 1.0, -1.0).astype( + np.float64 + ) + + # Run without constraint (unit signals) + asset_ret_unconstrained, port_ret_unconstrained, _ = backtest_multi_asset_core( + np.ascontiguousarray(close_2d), + np.ascontiguousarray(weights_2d), + max_asset_weight=1.0, + ) + + # Run with max_asset_weight=0.5 + asset_ret_constrained, port_ret_constrained, _ = backtest_multi_asset_core( + np.ascontiguousarray(close_2d), + np.ascontiguousarray(weights_2d), + max_asset_weight=0.5, + ) + + # Constrained returns should have smaller magnitude + assert np.abs(port_ret_constrained).sum() < np.abs( + port_ret_unconstrained + ).sum() or np.allclose( + np.abs(port_ret_constrained).sum(), + np.abs(port_ret_unconstrained).sum() * 0.5, + rtol=0.05, + ), "max_asset_weight=0.5 should reduce absolute returns by ~50%" + + def test_portfolio_max_gross_exposure_constrains_sum(self): + """max_gross_exposure=1.0 should limit total abs(weights).""" + rng = np.random.default_rng(55) + n_bars, n_assets = 80, 4 + close_2d = ( + np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0) + * 100.0 + ) + # Always long all assets = gross exposure of 4.0 + weights_2d = np.ones((n_bars, n_assets), dtype=np.float64) + + # With max_gross_exposure=1.0, total abs weight should be normalized to 1 + ar_constrained, pr_constrained, _ = backtest_multi_asset_core( + np.ascontiguousarray(close_2d), + np.ascontiguousarray(weights_2d), + max_gross_exposure=1.0, + ) + ar_unconstrained, pr_unconstrained, _ = backtest_multi_asset_core( + np.ascontiguousarray(close_2d), + np.ascontiguousarray(weights_2d), + ) + + # Constrained portfolio should have ~1/4 the returns magnitude + ratio = np.abs(pr_constrained).sum() / (np.abs(pr_unconstrained).sum() + 1e-12) + assert ratio < 0.5, ( + f"Expected constrained to be much smaller, got ratio={ratio:.3f}" + ) + + def test_portfolio_constraints_engine_builder(self): + """BacktestEngine.with_portfolio_constraints stores the parameters.""" + engine = BacktestEngine().with_portfolio_constraints( + max_asset_weight=0.3, + max_gross_exposure=1.5, + max_net_exposure=0.5, + ) + assert engine._max_asset_weight == pytest.approx(0.3) + assert engine._max_gross_exposure == pytest.approx(1.5) + assert engine._max_net_exposure == pytest.approx(0.5) + + def test_backtest_portfolio_with_constraints(self): + """backtest_portfolio accepts portfolio constraint kwargs.""" + from ferro_ta.analysis.backtest import backtest_portfolio + + rng = np.random.default_rng(11) + n_bars, n_assets = 60, 2 + close_2d = ( + np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0) + * 100.0 + ) + row_flags = (np.arange(n_bars) % 10 < 5)[:, None] + weights_2d = np.where(np.tile(row_flags, (1, n_assets)), 1.0, -1.0).astype( + np.float64 + ) + + result = backtest_portfolio( + close_2d, + weights_2d, + max_asset_weight=0.5, + max_gross_exposure=0.8, + ) + assert isinstance(result, PortfolioBacktestResult) + assert np.all(np.isfinite(result.portfolio_equity)) + + +# =========================================================================== +# Phase 3: Data & UX features +# =========================================================================== + + +class TestPhase3Features: + """Tests for Phase 3: resample, adjust, multitf, and plot modules.""" + + # ----------------------------------------------------------------------- + # Helpers + # ----------------------------------------------------------------------- + def _make_ohlcv(self, n=100, seed=42): + rng = np.random.default_rng(seed) + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.002, n)) + high = close * (1 + rng.uniform(0.001, 0.006, n)) + low = close * (1 - rng.uniform(0.001, 0.006, n)) + volume = rng.uniform(1_000, 10_000, n) + return open_, high, low, close, volume + + # ----------------------------------------------------------------------- + # 1. resample_ohlcv — factor=4, 20 bars → 5 coarse bars + # ----------------------------------------------------------------------- + def test_resample_ohlcv_factor4(self): + from ferro_ta.analysis.resample import resample_ohlcv + + o, h, l, c, v = self._make_ohlcv(n=20) + co, ch, cl, cc, cv = resample_ohlcv(o, h, l, c, v, factor=4) + + assert co.shape == (5,) + assert ch.shape == (5,) + assert cl.shape == (5,) + assert cc.shape == (5,) + assert cv.shape == (5,) + + # open = first bar of each group + for i in range(5): + assert co[i] == pytest.approx(o[i * 4]) + + # high = max of group + for i in range(5): + assert ch[i] == pytest.approx(h[i * 4 : i * 4 + 4].max()) + + # low = min of group + for i in range(5): + assert cl[i] == pytest.approx(l[i * 4 : i * 4 + 4].min()) + + # close = last bar of group + for i in range(5): + assert cc[i] == pytest.approx(c[i * 4 + 3]) + + # volume = sum of group + for i in range(5): + assert cv[i] == pytest.approx(v[i * 4 : i * 4 + 4].sum()) + + # ----------------------------------------------------------------------- + # 2. resample_ohlcv — non-divisible length: 22 bars, factor=4 → 5 coarse bars + # ----------------------------------------------------------------------- + def test_resample_ohlcv_non_divisible(self): + from ferro_ta.analysis.resample import resample_ohlcv + + o, h, l, c, v = self._make_ohlcv(n=22) + co, ch, cl, cc, cv = resample_ohlcv(o, h, l, c, v, factor=4) + + # 22 // 4 = 5 complete bars, last 2 fine bars are dropped + assert len(co) == 5 + assert len(ch) == 5 + + # ----------------------------------------------------------------------- + # 3. align_to_coarse — roundtrip test + # ----------------------------------------------------------------------- + def test_align_to_coarse_roundtrip(self): + from ferro_ta.analysis.resample import align_to_coarse + + coarse = np.array([10.0, 20.0, 30.0, 40.0, 50.0]) + factor = 4 + n_fine = 20 + + fine = align_to_coarse(coarse, factor, n_fine) + + assert len(fine) == n_fine + + for i, val in enumerate(coarse): + expected = np.full(factor, val) + npt.assert_array_equal(fine[i * factor : i * factor + factor], expected) + + # ----------------------------------------------------------------------- + # 4. adjust_for_splits — 2-for-1 split at bar 50 in 100-bar series + # ----------------------------------------------------------------------- + def test_adjust_for_splits_halves_historical(self): + from ferro_ta.analysis.adjust import adjust_for_splits + + close = np.ones(100) * 100.0 + adjusted = adjust_for_splits(close, split_factors=[2.0], split_indices=[50]) + + # Prices before split (bars 0-49) should be halved + npt.assert_array_almost_equal(adjusted[:50], np.full(50, 50.0)) + # Prices from split onwards unchanged + npt.assert_array_almost_equal(adjusted[50:], np.full(50, 100.0)) + + # ----------------------------------------------------------------------- + # 5. adjust_for_dividends — dividend at bar 50; prices before reduced + # ----------------------------------------------------------------------- + def test_adjust_for_dividends_reduces_historical(self): + from ferro_ta.analysis.adjust import adjust_for_dividends + + close = np.ones(100) * 100.0 + # bar 49 close = 100.0, dividend = 5.0 → factor = 95/100 = 0.95 + adjusted = adjust_for_dividends(close, dividends=[5.0], ex_date_indices=[50]) + + # Prices before ex-date should be scaled by 0.95 + expected_factor = (100.0 - 5.0) / 100.0 + npt.assert_array_almost_equal( + adjusted[:50], np.full(50, 100.0 * expected_factor) + ) + # Prices from ex-date onwards unchanged + npt.assert_array_almost_equal(adjusted[50:], np.full(50, 100.0)) + + # ----------------------------------------------------------------------- + # 6. adjust_ohlcv — volume doubles on 2-for-1 split (inverse adjustment) + # ----------------------------------------------------------------------- + def test_adjust_ohlcv_volume_increases_on_split(self): + from ferro_ta.analysis.adjust import adjust_ohlcv + + n = 100 + close = np.ones(n) * 100.0 + open_ = close.copy() + high = close.copy() + low = close.copy() + volume = np.ones(n) * 1000.0 + + ao, ah, al, ac, av = adjust_ohlcv( + open_, + high, + low, + close, + volume, + split_factors=[2.0], + split_indices=[50], + ) + + # Volume before the split is multiplied by factor (2x) — more shares pre-split + npt.assert_array_almost_equal(av[:50], np.full(50, 2000.0)) + # Volume at or after split unchanged + npt.assert_array_almost_equal(av[50:], np.full(50, 1000.0)) + + # Prices before split halved + npt.assert_array_almost_equal(ac[:50], np.full(50, 50.0)) + npt.assert_array_almost_equal(ac[50:], np.full(50, 100.0)) + + # ----------------------------------------------------------------------- + # 7. MultiTimeframeEngine — runs on 200 fine bars, returns valid result + # ----------------------------------------------------------------------- + def test_multitf_engine_runs(self): + from ferro_ta.analysis.multitf import MultiTimeframeEngine + + rng = np.random.default_rng(99) + n_fine = 200 + close_fine = np.cumprod(1 + rng.standard_normal(n_fine) * 0.005) * 100.0 + + result = ( + MultiTimeframeEngine(factor=4) + .with_htf_strategy("rsi_30_70") + .run(close_fine) + ) + + assert isinstance(result, AdvancedBacktestResult) + assert len(result.equity) == n_fine + assert np.all(np.isfinite(result.equity)) + assert result.equity[0] == pytest.approx(1.0, rel=1e-6) + + # ----------------------------------------------------------------------- + # 8. plot_backtest — returns a plotly Figure (skip if plotly not installed) + # ----------------------------------------------------------------------- + def test_plot_backtest_returns_figure(self): + pytest.importorskip("plotly", reason="plotly not installed") + from plotly.graph_objects import Figure + + from ferro_ta.analysis.plot import plot_backtest + + rng = np.random.default_rng(7) + n = 100 + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + high = close * 1.01 + low = close * 0.99 + open_ = close * 0.999 + + result = ( + BacktestEngine() + .with_ohlcv(high=high, low=low, open_=open_) + .run(close, strategy="rsi_30_70") + ) + + fig = plot_backtest(result, show=False, return_fig=True) + + assert isinstance(fig, Figure) + + +# =========================================================================== +# Phase 4: Regime Detection, Portfolio Optimization, PaperTrader +# =========================================================================== + + +class TestPhase4Features: + """Tests for Phase 4 differentiation features.""" + + # ----------------------------------------------------------------------- + # Helpers + # ----------------------------------------------------------------------- + + def _make_close(self, n: int = 300, seed: int = 77) -> np.ndarray: + rng = np.random.default_rng(seed) + return np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + + def _make_ohlcv_local(self, n: int = 300, seed: int = 77): + rng = np.random.default_rng(seed) + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.005, n)) + high = close * (1 + rng.uniform(0, 0.01, n)) + low = close * (1 - rng.uniform(0, 0.01, n)) + return open_, high, low, close + + # ----------------------------------------------------------------------- + # 1. detect_volatility_regime + # ----------------------------------------------------------------------- + + def test_volatility_regime_labels_three_states(self): + from ferro_ta.analysis.regime import detect_volatility_regime + + close = self._make_close(300) + labels = detect_volatility_regime(close, window=20, n_regimes=3) + assert labels.shape == (300,) + valid_values = {-1, 0, 1, 2} + assert set(np.unique(labels)).issubset(valid_values) + # Some valid (non-warmup) bars should be labeled + assert np.any(labels >= 0) + + # ----------------------------------------------------------------------- + # 2. detect_trend_regime + # ----------------------------------------------------------------------- + + def test_trend_regime_bull_bear(self): + from ferro_ta.analysis.regime import detect_trend_regime + + # Uptrend: price steadily rising + n = 300 + close_up = np.linspace(100, 200, n) + labels_up = detect_trend_regime(close_up, fast=10, slow=50) + valid = labels_up[labels_up != 0] + assert len(valid) > 0, "Expected some labeled bars after warmup" + # Most valid bars should be bull (1) + bull_frac = (valid == 1).sum() / len(valid) + assert bull_frac > 0.5, ( + f"Expected mostly bull bars in uptrend, got {bull_frac:.2%}" + ) + + # Downtrend: price steadily declining + close_dn = np.linspace(200, 100, n) + labels_dn = detect_trend_regime(close_dn, fast=10, slow=50) + valid_dn = labels_dn[labels_dn != 0] + assert len(valid_dn) > 0 + bear_frac = (valid_dn == -1).sum() / len(valid_dn) + assert bear_frac > 0.5, ( + f"Expected mostly bear bars in downtrend, got {bear_frac:.2%}" + ) + + # ----------------------------------------------------------------------- + # 3. detect_combined_regime + # ----------------------------------------------------------------------- + + def test_combined_regime_states(self): + from ferro_ta.analysis.regime import detect_combined_regime + + close = self._make_close(500) + labels = detect_combined_regime(close, vol_window=20, fast=20, slow=50) + assert labels.shape == (500,) + valid_values = {-1, 0, 1, 2, 3, 4, 5} + assert set(np.unique(labels)).issubset(valid_values) + + # ----------------------------------------------------------------------- + # 4. RegimeFilter + # ----------------------------------------------------------------------- + + def test_regime_filter_zeros_disallowed(self): + from ferro_ta.analysis.regime import RegimeFilter, detect_combined_regime + + n = 500 + close = self._make_close(n) + signals = np.ones(n) + + # Only allow regime 0 (bull + low vol) + rf = RegimeFilter(allowed_regimes=[0], vol_window=20, fast=20, slow=50) + filtered = rf.filter(signals, close) + + regimes = detect_combined_regime(close, vol_window=20, fast=20, slow=50) + # Bars NOT in regime 0 should have filtered signal = 0 + disallowed_mask = regimes != 0 + assert np.all(filtered[disallowed_mask] == 0.0) + # Bars in regime 0 should retain their signal + allowed_mask = regimes == 0 + if np.any(allowed_mask): + assert np.all(filtered[allowed_mask] == 1.0) + + # ----------------------------------------------------------------------- + # 5. mean_variance_optimize + # ----------------------------------------------------------------------- + + def test_mean_variance_weights_sum_to_one(self): + pytest.importorskip("scipy", reason="scipy not installed") + from ferro_ta.analysis.optimize import mean_variance_optimize + + rng = np.random.default_rng(0) + returns = rng.standard_normal((252, 4)) * 0.01 + w = mean_variance_optimize(returns) + assert w.shape == (4,) + assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6) + assert np.all(w >= -1e-9), "Weights should be non-negative (no short)" + + # ----------------------------------------------------------------------- + # 6. risk_parity_optimize + # ----------------------------------------------------------------------- + + def test_risk_parity_weights_sum_to_one(self): + pytest.importorskip("scipy", reason="scipy not installed") + from ferro_ta.analysis.optimize import risk_parity_optimize + + rng = np.random.default_rng(1) + returns = rng.standard_normal((252, 3)) * 0.01 + w = risk_parity_optimize(returns) + assert w.shape == (3,) + assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6) + assert np.all(w >= 0.0) + + # ----------------------------------------------------------------------- + # 7. max_sharpe_optimize + # ----------------------------------------------------------------------- + + def test_max_sharpe_weights_sum_to_one(self): + pytest.importorskip("scipy", reason="scipy not installed") + from ferro_ta.analysis.optimize import max_sharpe_optimize + + rng = np.random.default_rng(2) + returns = rng.standard_normal((252, 5)) * 0.01 + w = max_sharpe_optimize(returns) + assert w.shape == (5,) + assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6) + assert np.all(w >= -1e-9) + + # ----------------------------------------------------------------------- + # 8. PortfolioOptimizer fluent builder + # ----------------------------------------------------------------------- + + def test_portfolio_optimizer_fluent(self): + pytest.importorskip("scipy", reason="scipy not installed") + from ferro_ta.analysis.optimize import PortfolioOptimizer + + rng = np.random.default_rng(3) + returns = rng.standard_normal((252, 3)) * 0.01 + + for method in ("min_variance", "risk_parity", "max_sharpe"): + w = ( + PortfolioOptimizer() + .with_method(method) + .with_lookback(100) + .optimize(returns) + ) + assert w.shape == (3,) + assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6) + + # ----------------------------------------------------------------------- + # 9. PaperTrader: basic fills + # ----------------------------------------------------------------------- + + def test_paper_trader_fills_on_signal(self): + from ferro_ta.analysis.live import PaperTrader + + rng = np.random.default_rng(10) + n = 20 + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.003, n)) + high = close * (1 + rng.uniform(0.001, 0.005, n)) + low = close * (1 - rng.uniform(0.001, 0.005, n)) + + trader = PaperTrader(initial_capital=100_000) + signals = np.where(np.arange(n) % 6 < 3, 1.0, -1.0).astype(float) + + results = [] + for i in range(n): + r = trader.on_bar(open_[i], high[i], low[i], close[i], signals[i]) + results.append(r) + + # Should have produced at least one fill after first bar + fills = [r for r in results if r.filled] + assert len(fills) > 0 + # Equity curve length should match bars + assert len(trader.equity_curve) == n + # Final equity should be finite + assert math.isfinite(trader.equity) + + # ----------------------------------------------------------------------- + # 10. PaperTrader: stop-loss triggers + # ----------------------------------------------------------------------- + + def test_paper_trader_stop_loss_triggers(self): + from ferro_ta.analysis.live import PaperTrader + + # Price rises on entry then falls sharply — SL should trigger + n = 20 + close = np.array( + [100.0] * 5 + + [98.0, 96.0, 94.0, 92.0, 90.0] # declining + + [88.0, 86.0, 84.0, 82.0, 80.0, 78.0, 76.0, 74.0, 72.0, 70.0], + dtype=float, + ) + open_ = close * 1.001 + high = close * 1.005 + low = close * 0.99 # Low drops to trigger SL + + sl_pct = 0.03 # 3% stop-loss + trader = PaperTrader(initial_capital=100_000, stop_loss_pct=sl_pct) + + # Signal: go long on bar 0 + signals = np.zeros(n) + signals[0] = 1.0 # enter long + + for i in range(n): + trader.on_bar(open_[i], high[i], low[i], close[i], signals[i]) + + # With 3% SL and price dropping >3% below entry, we expect a trade to close + # Final position should be 0 (SL triggered exit) + assert trader.position == 0.0 or len(trader.trades) > 0 + + # ----------------------------------------------------------------------- + # 11. PaperTrader: reset clears state + # ----------------------------------------------------------------------- + + def test_paper_trader_reset_clears_state(self): + from ferro_ta.analysis.live import PaperTrader + + rng = np.random.default_rng(20) + n = 30 + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + open_ = close * 0.999 + high = close * 1.01 + low = close * 0.99 + signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(float) + + trader = PaperTrader(initial_capital=50_000) + for i in range(n): + trader.on_bar(open_[i], high[i], low[i], close[i], signals[i]) + + assert len(trader.equity_curve) > 0 + + trader.reset() + + assert trader.position == 0.0 + assert trader.equity == pytest.approx(1.0) + assert len(trader.trades) == 0 + assert len(trader.equity_curve) == 0 + assert trader.equity_abs == pytest.approx(50_000.0) + + # ----------------------------------------------------------------------- + # 12. PaperTrader equity matches backtest_ohlcv_core + # ----------------------------------------------------------------------- + + def test_paper_trader_equity_matches_backtest(self): + from ferro_ta.analysis.live import PaperTrader + + rng = np.random.default_rng(42) + n = 50 + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.003, n)) + high = close * (1 + rng.uniform(0.001, 0.005, n)) + low = close * (1 - rng.uniform(0.001, 0.005, n)) + signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64) + + # Vectorized Rust engine + _, _, _, _, eq_rust = backtest_ohlcv_core(open_, high, low, close, signals) + + # PaperTrader bar-by-bar + trader = PaperTrader(initial_capital=100_000) + for i in range(n): + trader.on_bar(open_[i], high[i], low[i], close[i], signals[i]) + + eq_paper = np.array(trader.equity_curve) + assert eq_paper.shape == eq_rust.shape + npt.assert_allclose( + eq_paper, + eq_rust, + rtol=1e-6, + atol=1e-9, + err_msg="PaperTrader equity curve does not match backtest_ohlcv_core", + ) diff --git a/tests/unit/analysis/test_backtest_v2.py b/tests/unit/analysis/test_backtest_v2.py new file mode 100644 index 0000000..440da41 --- /dev/null +++ b/tests/unit/analysis/test_backtest_v2.py @@ -0,0 +1,546 @@ +""" +v1.1.0 backtest feature tests. + +Covers: +- CommissionModel: total_cost, presets, round-trip JSON, save/load +- Currency: INR/USD formatting, from_code lookup +- BacktestEngine: initial_capital, commission_model, trailing_stop, benchmark +- AdvancedBacktestResult: equity_abs, pnl_abs in trade log, summary fields +- Volatility-target position sizing +- Benchmark comparison metrics +""" + +from __future__ import annotations + +import os +import tempfile + +import numpy as np +import pytest +from ferro_ta._ferro_ta import CommissionModel + +from ferro_ta.analysis.backtest import ( + EUR, + GBP, + INR, + JPY, + USD, + USDT, + BacktestEngine, + Currency, + format_currency, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def close_500(): + """500-bar synthetic close price series.""" + rng = np.random.default_rng(12345) + return np.cumprod(1.0 + rng.standard_normal(500) * 0.01) * 100.0 + + +@pytest.fixture +def ohlcv_500(close_500): + close = close_500 + high = close * 1.005 + low = close * 0.995 + open_ = close * 0.999 + volume = np.full(len(close), 1_000_000.0) + return open_, high, low, close, volume + + +# =========================================================================== +# TestCommissionModel +# =========================================================================== + + +class TestCommissionModel: + def test_zero_model_costs_nothing(self): + m = CommissionModel.zero() + assert m.total_cost(100_000, 1, True) == 0.0 + assert m.total_cost(100_000, 1, False) == 0.0 + + def test_flat_per_order(self): + m = CommissionModel() + m.flat_per_order = 20.0 + assert m.total_cost(100_000, 1, True) == pytest.approx(20.0) + assert m.total_cost(100_000, 1, False) == pytest.approx(20.0) + + def test_max_brokerage_cap(self): + m = CommissionModel() + m.flat_per_order = 0.0 + m.rate_of_value = 0.001 # 0.1% + m.max_brokerage = 20.0 + # 0.1% of 50_000 = 50, capped at 20 + assert m.total_cost(50_000, 1, True) == pytest.approx(20.0) + # 0.1% of 5_000 = 5, not capped + assert m.total_cost(5_000, 1, True) == pytest.approx(5.0) + + def test_stt_buy_side_only(self): + m = CommissionModel() + m.stt_rate = 0.001 + m.stt_on_buy = True + m.stt_on_sell = False + buy_cost = m.total_cost(100_000, 1, True) + sell_cost = m.total_cost(100_000, 1, False) + assert buy_cost == pytest.approx(100.0) + assert sell_cost == pytest.approx(0.0) + + def test_stt_sell_side_only(self): + m = CommissionModel() + m.stt_rate = 0.00025 + m.stt_on_buy = False + m.stt_on_sell = True + buy_cost = m.total_cost(100_000, 1, True) + sell_cost = m.total_cost(100_000, 1, False) + assert buy_cost == pytest.approx(0.0) + assert sell_cost == pytest.approx(25.0) + + def test_gst_on_brokerage_exchange_not_stt(self): + m = CommissionModel() + m.flat_per_order = 20.0 + m.exchange_charges_rate = 0.0001 + m.gst_rate = 0.18 + m.stt_rate = 0.001 + m.stt_on_sell = True + # GST = 0.18 * (20 + 0.0001 * 100_000) = 0.18 * 30 = 5.4 + # STT = 100 (sell side) + total = m.total_cost(100_000, 1, False) + expected_gst = 0.18 * (20.0 + 0.0001 * 100_000) + assert total == pytest.approx(20.0 + 100.0 + 0.0001 * 100_000 + expected_gst) + + def test_stamp_duty_buy_only(self): + m = CommissionModel() + m.stamp_duty_rate = 0.00015 + buy_cost = m.total_cost(100_000, 1, True) + sell_cost = m.total_cost(100_000, 1, False) + assert buy_cost == pytest.approx(15.0) + assert sell_cost == pytest.approx(0.0) + + def test_per_lot_charge(self): + m = CommissionModel() + m.per_lot = 2.0 + # 5 lots + assert m.total_cost(50_000, 5, True) == pytest.approx(10.0) + + def test_cost_fraction(self): + m = CommissionModel() + m.flat_per_order = 20.0 + frac = m.cost_fraction(100_000, 1, True, 100_000.0) + assert frac == pytest.approx(20.0 / 100_000.0) + + def test_cost_fraction_zero_capital(self): + m = CommissionModel() + m.flat_per_order = 20.0 + assert m.cost_fraction(100_000, 1, True, 0.0) == 0.0 + + def test_proportional_preset(self): + m = CommissionModel.proportional(0.001) + assert m.total_cost(100_000, 1, True) == pytest.approx(100.0) + assert m.gst_rate == 0.0 + + def test_repr_contains_key_fields(self): + m = CommissionModel.equity_delivery_india() + r = repr(m) + assert "CommissionModel" in r + assert "lot_size" in r + + +class TestCommissionPresets: + def test_equity_delivery_india_smoke(self): + m = CommissionModel.equity_delivery_india() + # Buy ₹1L trade: brokerage cap ₹20, STT ₹100 (both sides) + cost = m.total_cost(100_000, 1, True) + assert cost > 0.0 + assert cost < 500.0 # sanity upper bound + # Brokerage should be capped at ₹20 + assert m.flat_per_order == 0.0 + assert m.max_brokerage == pytest.approx(20.0) + assert m.stt_on_buy is True + assert m.stt_on_sell is True + + def test_equity_intraday_india_smoke(self): + m = CommissionModel.equity_intraday_india() + cost_buy = m.total_cost(100_000, 1, True) + cost_sell = m.total_cost(100_000, 1, False) + # STT only on sell side for intraday + assert m.stt_on_buy is False + assert m.stt_on_sell is True + assert cost_sell > cost_buy # sell has more cost (STT) + + def test_futures_india_smoke(self): + m = CommissionModel.futures_india() + assert m.flat_per_order == pytest.approx(20.0) + assert m.stt_on_buy is False + assert m.stt_on_sell is True + assert m.lot_size == pytest.approx(25.0) + + def test_options_india_smoke(self): + m = CommissionModel.options_india() + assert m.flat_per_order == pytest.approx(20.0) + assert m.stt_rate == pytest.approx(0.0015) + assert m.lot_size == pytest.approx(25.0) + + +class TestCommissionFix: + """The old 'commission_per_trade=20.0' bug would subtract ₹20 from 1.0-normalized + equity — a 2000% error. The new model correctly computes 0.02% fraction.""" + + def test_flat_20_on_1L_capital_is_tiny_fraction(self): + m = CommissionModel() + m.flat_per_order = 20.0 + frac = m.cost_fraction(100_000, 1, True, 100_000.0) + # ₹20 / ₹100_000 = 0.02% + assert frac == pytest.approx(20.0 / 100_000.0, rel=1e-6) + assert frac < 0.01 # definitely not 2000% + + def test_commission_reduces_equity_vs_no_commission(self): + rng = np.random.default_rng(99) + close = np.cumprod(1.0 + rng.standard_normal(200) * 0.01) * 100.0 + m = CommissionModel.equity_intraday_india() + r_comm = ( + BacktestEngine() + .with_commission_model(m) + .with_initial_capital(100_000) + .run(close, "sma_crossover") + ) + r_none = ( + BacktestEngine().with_initial_capital(100_000).run(close, "sma_crossover") + ) + # Commission should reduce final equity (or keep equal if zero trades) + assert r_comm.final_equity <= r_none.final_equity + + +class TestCommissionSaveLoad: + def test_to_json_from_json_round_trip(self): + m = CommissionModel.equity_delivery_india() + j = m.to_json() + m2 = CommissionModel.from_json(j) + assert m == m2 + assert m2.stt_rate == pytest.approx(m.stt_rate) + assert m2.lot_size == pytest.approx(m.lot_size) + assert m2.gst_rate == pytest.approx(m.gst_rate) + + def test_save_load_round_trip(self): + m = CommissionModel.futures_india() + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + path = f.name + try: + m.save(path) + assert os.path.exists(path) + m2 = CommissionModel.load(path) + assert m == m2 + finally: + os.unlink(path) + + def test_from_json_invalid_raises(self): + with pytest.raises(Exception): + CommissionModel.from_json("{invalid json") + + def test_load_missing_file_raises(self): + with pytest.raises(Exception): + CommissionModel.load("/nonexistent/path/commission.json") + + +# =========================================================================== +# TestCurrency +# =========================================================================== + + +class TestCurrency: + def test_inr_lakh_grouping(self): + assert INR.format(123456.78) == "₹1,23,456.78" + assert INR.format(1000000.0) == "₹10,00,000.00" + assert INR.format(10000000.0) == "₹1,00,00,000.00" + assert INR.format(100.0) == "₹100.00" + assert INR.format(1234.5) == "₹1,234.50" + + def test_inr_negative(self): + result = INR.format(-5000.0) + assert result.startswith("-₹") + assert "5,000.00" in result + + def test_usd_standard_grouping(self): + assert USD.format(1234567.89) == "$1,234,567.89" + assert USD.format(0.5) == "$0.50" + assert USD.format(1000.0) == "$1,000.00" + + def test_jpy_no_decimals(self): + result = JPY.format(1000000.0) + assert result == "¥1,000,000" + + def test_eur_format(self): + assert "€" in EUR.format(100.0) + + def test_gbp_format(self): + assert "£" in GBP.format(100.0) + + def test_usdt_format(self): + assert "₮" in USDT.format(100.0) + + def test_format_currency_helper(self): + assert format_currency(123456.78) == "₹1,23,456.78" + assert format_currency(1000.0, USD) == "$1,000.00" + + def test_currency_immutable(self): + with pytest.raises(AttributeError): + INR.code = "USD" # type: ignore[misc] + + def test_currency_equality(self): + c1 = Currency.from_code("INR") + assert c1 == INR + assert INR != USD + + def test_currency_hash_usable_in_dict(self): + d = {INR: 100_000, USD: 100} + assert d[INR] == 100_000 + + +# =========================================================================== +# TestInitialCapital +# =========================================================================== + + +class TestInitialCapital: + def test_equity_abs_shape(self, close_500): + result = ( + BacktestEngine() + .with_initial_capital(200_000) + .run(close_500, "sma_crossover") + ) + assert result.equity_abs.shape == result.equity.shape + + def test_equity_abs_is_equity_times_capital(self, close_500): + capital = 150_000.0 + result = ( + BacktestEngine() + .with_initial_capital(capital) + .run(close_500, "sma_crossover") + ) + np.testing.assert_allclose(result.equity_abs, result.equity * capital) + + def test_summary_contains_capital_fields(self, close_500): + capital = 100_000.0 + result = ( + BacktestEngine() + .with_initial_capital(capital) + .run(close_500, "sma_crossover") + ) + s = result.summary() + assert "initial_capital" in s + assert "final_capital" in s + assert "absolute_pnl" in s + assert s["initial_capital"] == pytest.approx(capital) + assert s["final_capital"] == pytest.approx(result.equity_abs[-1]) + assert s["absolute_pnl"] == pytest.approx(s["final_capital"] - capital) + + def test_pnl_abs_in_trade_log(self, close_500, ohlcv_500): + open_, high, low, close, _ = ohlcv_500 + capital = 100_000.0 + result = ( + BacktestEngine() + .with_initial_capital(capital) + .with_ohlcv(high=high, low=low, open_=open_) + .run(close, "sma_crossover") + ) + if result.trades is not None and len(result.trades) > 0: + assert "pnl_abs" in result.trades.columns + np.testing.assert_allclose( + result.trades["pnl_abs"].values, + result.trades["pnl_pct"].values * capital, + ) + + +class TestINRRepr: + def test_repr_shows_inr_symbol(self, close_500): + result = ( + BacktestEngine() + .with_currency(INR) + .with_initial_capital(100_000) + .run(close_500, "sma_crossover") + ) + r = repr(result) + assert "₹" in r + + def test_currency_code_in_summary(self, close_500): + result = ( + BacktestEngine() + .with_currency("USD") + .with_initial_capital(10_000) + .run(close_500, "sma_crossover") + ) + s = result.summary() + assert s["currency"] == "USD" + + def test_unknown_currency_raises(self): + with pytest.raises(Exception, match="Unknown currency"): + BacktestEngine().with_currency("XYZ") + + +# =========================================================================== +# TestVolatilityTargetSizing +# =========================================================================== + + +class TestVolatilityTargetSizing: + def test_vol_target_runs_without_error(self, close_500): + result = ( + BacktestEngine() + .with_position_sizing("volatility_target", target_vol=0.10) + .run(close_500, "sma_crossover") + ) + assert len(result.equity) == len(close_500) + assert np.isfinite(result.final_equity) + + def test_vol_target_signals_are_scaled(self, close_500): + # With very low target vol the strategy should have fewer active positions + result_low = ( + BacktestEngine() + .with_position_sizing("volatility_target", target_vol=0.01) + .run(close_500, "sma_crossover") + ) + result_high = ( + BacktestEngine() + .with_position_sizing("volatility_target", target_vol=1.0) + .run(close_500, "sma_crossover") + ) + # Lower vol target → lower absolute position sizes → lower annualised vol + low_std = float(np.nanstd(result_low.strategy_returns)) + high_std = float(np.nanstd(result_high.strategy_returns)) + assert low_std <= high_std or np.isclose(low_std, high_std, rtol=0.5) + + +# =========================================================================== +# TestBenchmark +# =========================================================================== + + +class TestBenchmark: + def test_benchmark_metrics_present(self, close_500): + rng = np.random.default_rng(77) + benchmark = np.cumprod(1.0 + rng.standard_normal(500) * 0.008) * 100.0 + result = ( + BacktestEngine().with_benchmark(benchmark).run(close_500, "sma_crossover") + ) + s = result.summary() + assert "alpha" in s + assert "beta" in s + assert "tracking_error" in s + assert "information_ratio" in s + assert "benchmark_cagr" in s + + def test_identical_strategy_benchmark_has_low_tracking_error(self, close_500): + # When strategy returns = benchmark returns, tracking error ≈ 0 + # Use the equity as its own benchmark + result = ( + BacktestEngine().with_benchmark(close_500).run(close_500, "sma_crossover") + ) + m = result.metrics + # Beta should be finite + assert np.isfinite(m.get("beta", float("nan"))) + + def test_benchmark_wrong_length_ignored(self, close_500): + short_bench = close_500[:100] + # Should not raise — benchmark mismatch is silently ignored + result = ( + BacktestEngine().with_benchmark(short_bench).run(close_500, "sma_crossover") + ) + # alpha should NOT appear (length mismatch) + assert "alpha" not in result.metrics + + +# =========================================================================== +# TestTrailingStop +# =========================================================================== + + +class TestTrailingStop: + def test_trailing_stop_runs(self, ohlcv_500, close_500): + open_, high, low, close, _ = ohlcv_500 + result = ( + BacktestEngine() + .with_ohlcv(high=high, low=low, open_=open_) + .with_trailing_stop(0.02) + .run(close, "sma_crossover") + ) + assert len(result.equity) == len(close) + assert np.isfinite(result.final_equity) + + def test_trailing_stop_reduces_losses_on_downtrend(self): + """Trailing stop should exit longs earlier on a falling market.""" + # Construct a clear downtrend after initial rise + prices = np.concatenate( + [ + np.linspace(100, 120, 50), # rise (signal stays long) + np.linspace(120, 60, 150), # sharp fall + ] + ) + high = prices * 1.002 + low = prices * 0.998 + open_ = prices * 0.999 + + result_trail = ( + BacktestEngine() + .with_ohlcv(high=high, low=low, open_=open_) + .with_trailing_stop(0.03) + .run(prices, "sma_crossover") + ) + result_no_trail = ( + BacktestEngine() + .with_ohlcv(high=high, low=low, open_=open_) + .run(prices, "sma_crossover") + ) + # Trailing stop should yield better (or equal) max drawdown + dd_trail = result_trail.metrics.get("max_drawdown", 0.0) + dd_no_trail = result_no_trail.metrics.get("max_drawdown", 0.0) + # max_drawdown is negative; higher value = smaller drawdown + assert dd_trail >= dd_no_trail - 0.05 # allow 5% tolerance + + +# =========================================================================== +# TestBacktestEngineChaining +# =========================================================================== + + +class TestBacktestEngineChaining: + def test_full_chain_runs(self, close_500, ohlcv_500): + open_, high, low, close, _ = ohlcv_500 + rng = np.random.default_rng(42) + benchmark = np.cumprod(1.0 + rng.standard_normal(500) * 0.008) * 100.0 + + result = ( + BacktestEngine() + .with_currency("INR") + .with_initial_capital(100_000) + .with_commission_model(CommissionModel.equity_intraday_india()) + .with_trailing_stop(0.02) + .with_benchmark(benchmark) + .with_ohlcv(high=high, low=low, open_=open_) + .run(close, "sma_crossover") + ) + assert len(result.equity) == len(close) + assert result.currency == INR + assert result.initial_capital == pytest.approx(100_000.0) + assert np.isfinite(result.final_equity) + + s = result.summary() + assert s["currency"] == "INR" + assert "alpha" in s # benchmark was set + + def test_to_equity_dataframe(self, close_500): + result = ( + BacktestEngine() + .with_initial_capital(50_000) + .run(close_500, "sma_crossover") + ) + df = result.to_equity_dataframe() + assert "equity" in df.columns + assert "equity_abs" in df.columns + assert "strategy_returns" in df.columns + assert "drawdown" in df.columns + assert len(df) == len(close_500) + np.testing.assert_allclose(df["equity_abs"].values, result.equity_abs) diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py new file mode 100644 index 0000000..10b06e6 --- /dev/null +++ b/tests/unit/helpers.py @@ -0,0 +1,159 @@ +"""Shared test helpers for ferro_ta unit tests. + +This module consolidates common assertion patterns and data-generation +utilities that are duplicated across multiple test files. Importing +from here keeps individual test modules DRY and makes it easier to +update assertion logic in one place. + +Usage +----- + from tests.unit.helpers import ( + nan_count, finite, assert_nan_warmup, assert_output_length, + assert_finite_values, assert_range, make_ohlcv, + ) + +Note: Each test file that already has inline helpers continues to work +unchanged. These helpers are provided for *new* tests and for gradual +consolidation of existing ones. +""" + +from __future__ import annotations + +import numpy as np + +# --------------------------------------------------------------------------- +# Array inspection helpers +# --------------------------------------------------------------------------- + + +def nan_count(arr: np.ndarray) -> int: + """Return the number of NaN entries in *arr*. + + Equivalent to the ``_nan_count`` functions duplicated in: + - tests/unit/test_ferro_ta.py + - tests/integration/test_vs_talib.py + - tests/integration/test_vs_pandas_ta.py + """ + return int(np.sum(np.isnan(arr))) + + +def finite(arr: np.ndarray) -> np.ndarray: + """Return only the finite (non-NaN) elements of *arr*. + + Equivalent to the ``_finite`` helpers in: + - tests/unit/test_ferro_ta.py + - tests/unit/streaming/test_streaming.py + """ + return arr[~np.isnan(arr)] + + +# --------------------------------------------------------------------------- +# Common assertion helpers +# --------------------------------------------------------------------------- + + +def assert_output_length(result: np.ndarray, expected_length: int) -> None: + """Assert the indicator output has the expected length. + + This pattern (``assert len(result) == len(PRICES)``) appears 82+ times + across the test suite. + """ + assert len(result) == expected_length, ( + f"Expected output length {expected_length}, got {len(result)}" + ) + + +def assert_nan_warmup(result: np.ndarray, warmup: int) -> None: + """Assert that the first *warmup* values are NaN and that at least + one value after the warmup period is finite. + + This pattern (``assert np.all(np.isnan(result[:N]))``) appears 36+ + times in indicator tests. + """ + assert np.all(np.isnan(result[:warmup])), ( + f"Expected first {warmup} values to be NaN" + ) + if len(result) > warmup: + assert np.any(np.isfinite(result[warmup:])), ( + f"Expected at least one finite value after warmup index {warmup}" + ) + + +def assert_finite_values(arr: np.ndarray) -> None: + """Assert that *all* non-NaN values are finite (not +/-inf). + + The pattern ``np.all(np.isfinite(arr[~np.isnan(arr)]))`` appears + 60+ times across the test suite. + """ + valid = arr[~np.isnan(arr)] + assert np.all(np.isfinite(valid)), "Found non-finite (inf) values in output" + + +def assert_range( + arr: np.ndarray, + lo: float = 0.0, + hi: float = 100.0, + *, + ignore_nan: bool = True, +) -> None: + """Assert every (non-NaN) value in *arr* falls within [lo, hi]. + + The ``valid >= 0 and valid <= 100`` pattern appears 10+ times for + oscillator-type indicators (RSI, WILLR, CMO, etc.). + """ + values = arr[~np.isnan(arr)] if ignore_nan else arr + assert np.all(values >= lo), f"Found value below {lo}: {values.min()}" + assert np.all(values <= hi), f"Found value above {hi}: {values.max()}" + + +def assert_close( + actual: np.ndarray, + expected: np.ndarray, + *, + rtol: float = 1e-6, + atol: float = 0.0, + ignore_nan: bool = True, +) -> None: + """Assert element-wise closeness, optionally skipping NaN positions. + + Thin wrapper around ``np.testing.assert_allclose`` that mirrors the + NaN-stripping pattern seen in integration tests. + """ + if ignore_nan: + mask = ~(np.isnan(actual) | np.isnan(expected)) + actual = actual[mask] + expected = expected[mask] + np.testing.assert_allclose(actual, expected, rtol=rtol, atol=atol) + + +# --------------------------------------------------------------------------- +# Data generation helpers +# --------------------------------------------------------------------------- + + +def make_ohlcv( + n: int = 100, + seed: int = 42, + base_price: float = 100.0, +) -> dict[str, np.ndarray]: + """Generate reproducible synthetic OHLCV data. + + This pattern is duplicated across many test files with slight + variations (different seeds, base prices, spread logic). Using + this helper ensures consistent generation logic. + + Returns a dict with keys: close, high, low, open, volume. + """ + rng = np.random.default_rng(seed) + close = base_price + np.cumsum(rng.normal(0, 0.5, n)) + high = close + np.abs(rng.normal(0, 0.3, n)) + low = close - np.abs(rng.normal(0, 0.3, n)) + open_ = close + rng.normal(0, 0.1, n) + volume = rng.uniform(1000, 5000, n) + return { + "close": close, + "high": high, + "low": low, + "open": open_, + "volume": volume, + } diff --git a/tests/unit/streaming/test_streaming.py b/tests/unit/streaming/test_streaming.py new file mode 100644 index 0000000..841ef38 --- /dev/null +++ b/tests/unit/streaming/test_streaming.py @@ -0,0 +1,387 @@ +"""Tests for ferro_ta streaming / incremental indicators.""" + +import math + +import numpy as np +import pytest + +from ferro_ta import EMA, RSI, SMA +from ferro_ta.data.streaming import StreamingEMA, StreamingRSI, StreamingSMA + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +PRICES = np.array( + [ + 44.34, + 44.09, + 44.15, + 43.61, + 44.33, + 44.83, + 45.10, + 45.15, + 43.61, + 44.33, + 44.83, + 45.10, + 45.15, + 43.61, + 44.33, + ], + dtype=np.float64, +) + + +def _finite(arr: np.ndarray) -> np.ndarray: + return arr[~np.isnan(arr)] + + +# --------------------------------------------------------------------------- +# StreamingSMA +# --------------------------------------------------------------------------- + + +class TestStreamingSMA: + def test_basic_values(self): + """Feed known values, verify manually computed SMA.""" + sma = StreamingSMA(period=3) + assert math.isnan(sma.update(1.0)) + assert math.isnan(sma.update(2.0)) + assert math.isclose(sma.update(3.0), 2.0) + assert math.isclose(sma.update(4.0), 3.0) + assert math.isclose(sma.update(5.0), 4.0) + + def test_matches_batch_sma(self): + """Streaming SMA final values must match batch SMA on the same data.""" + period = 5 + batch = SMA(PRICES, timeperiod=period) + sma = StreamingSMA(period=period) + for i, price in enumerate(PRICES): + val = sma.update(price) + if math.isnan(batch[i]): + assert math.isnan(val), f"Expected NaN at index {i}" + else: + assert math.isclose(val, batch[i], rel_tol=1e-10), ( + f"Mismatch at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_period_property(self): + sma = StreamingSMA(period=7) + assert sma.period == 7 + + def test_warmup_returns_nan(self): + """First period-1 updates must return NaN.""" + period = 4 + sma = StreamingSMA(period=period) + for i in range(period - 1): + assert math.isnan(sma.update(float(i + 1))) + # The period-th update should NOT be NaN + assert not math.isnan(sma.update(float(period))) + + def test_single_value_period_1(self): + """Period=1 means every value is immediately returned.""" + sma = StreamingSMA(period=1) + assert math.isclose(sma.update(42.0), 42.0) + assert math.isclose(sma.update(99.0), 99.0) + + def test_reset(self): + """After reset, the indicator should behave as freshly constructed.""" + sma = StreamingSMA(period=3) + sma.update(10.0) + sma.update(20.0) + result_before_reset = sma.update(30.0) + assert math.isclose(result_before_reset, 20.0) + + sma.reset() + # After reset, warmup restarts + assert math.isnan(sma.update(100.0)) + assert math.isnan(sma.update(200.0)) + assert math.isclose(sma.update(300.0), 200.0) + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + StreamingSMA(period=0) + + def test_repr(self): + sma = StreamingSMA(period=5) + assert "StreamingSMA" in repr(sma) + assert "5" in repr(sma) + + +# --------------------------------------------------------------------------- +# StreamingEMA +# --------------------------------------------------------------------------- + + +class TestStreamingEMA: + def test_basic_seeding(self): + """EMA seeds from the first `period` values using their SMA.""" + ema = StreamingEMA(period=3) + assert math.isnan(ema.update(1.0)) + assert math.isnan(ema.update(2.0)) + # Seed = SMA(1,2,3) = 2.0 + seed = ema.update(3.0) + assert math.isclose(seed, 2.0) + + def test_matches_batch_ema(self): + """Streaming EMA must match batch EMA on the same data.""" + period = 5 + batch = EMA(PRICES, timeperiod=period) + ema = StreamingEMA(period=period) + for i, price in enumerate(PRICES): + val = ema.update(price) + if math.isnan(batch[i]): + assert math.isnan(val), f"Expected NaN at index {i}" + else: + assert math.isclose(val, batch[i], rel_tol=1e-10), ( + f"Mismatch at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_warmup_returns_nan(self): + period = 5 + ema = StreamingEMA(period=period) + for i in range(period - 1): + assert math.isnan(ema.update(float(i + 1))) + assert not math.isnan(ema.update(float(period))) + + def test_ema_differs_from_sma_after_warmup(self): + """After warmup, EMA and SMA should diverge for non-constant data.""" + period = 3 + prices = [1.0, 2.0, 3.0, 10.0, 11.0] + sma = StreamingSMA(period=period) + ema = StreamingEMA(period=period) + sma_vals = [sma.update(p) for p in prices] + ema_vals = [ema.update(p) for p in prices] + # At the seed point they should match (both are SMA of first 3) + assert math.isclose(sma_vals[2], ema_vals[2]) + # After the seed they should diverge + assert not math.isclose(sma_vals[-1], ema_vals[-1], rel_tol=1e-9) + + def test_reset(self): + ema = StreamingEMA(period=3) + for p in [10.0, 20.0, 30.0, 40.0]: + ema.update(p) + ema.reset() + # After reset, warmup restarts + assert math.isnan(ema.update(1.0)) + assert math.isnan(ema.update(2.0)) + assert math.isclose(ema.update(3.0), 2.0) + + def test_period_property(self): + ema = StreamingEMA(period=10) + assert ema.period == 10 + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + StreamingEMA(period=0) + + def test_single_value_period_1(self): + ema = StreamingEMA(period=1) + assert math.isclose(ema.update(42.0), 42.0) + assert math.isclose(ema.update(50.0), 50.0) + + def test_repr(self): + ema = StreamingEMA(period=12) + assert "StreamingEMA" in repr(ema) + assert "12" in repr(ema) + + +# --------------------------------------------------------------------------- +# StreamingRSI +# --------------------------------------------------------------------------- + + +class TestStreamingRSI: + def test_matches_batch_rsi(self): + """Streaming RSI must match batch RSI on the same data.""" + period = 5 + batch = RSI(PRICES, timeperiod=period) + rsi = StreamingRSI(period=period) + for i, price in enumerate(PRICES): + val = rsi.update(price) + if math.isnan(batch[i]): + assert math.isnan(val), f"Expected NaN at index {i}" + else: + assert math.isclose(val, batch[i], rel_tol=1e-8), ( + f"Mismatch at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_warmup_returns_nan(self): + """RSI needs period+1 bars (1 for first prev, then period deltas).""" + period = 5 + rsi = StreamingRSI(period=period) + # First bar: sets prev, returns NaN + assert math.isnan(rsi.update(50.0)) + # Next period-1 bars: accumulating deltas, returns NaN + for i in range(period - 1): + assert math.isnan(rsi.update(50.0 + i)) + # The (period+1)-th bar should produce a value + assert not math.isnan(rsi.update(55.0)) + + def test_rsi_range(self): + """All finite RSI values must be in [0, 100].""" + rsi = StreamingRSI(period=5) + for price in PRICES: + val = rsi.update(price) + if not math.isnan(val): + assert 0.0 <= val <= 100.0, f"RSI out of range: {val}" + + def test_constant_prices(self): + """Constant prices produce no gains or losses -- RSI should be 100 + (avg_loss == 0 leads to RS = infinity -> RSI = 100).""" + rsi = StreamingRSI(period=5) + results = [rsi.update(50.0) for _ in range(20)] + finite = [v for v in results if not math.isnan(v)] + assert len(finite) > 0 + for v in finite: + assert math.isclose(v, 100.0) or math.isclose(v, 0.0) or (0.0 <= v <= 100.0) + + def test_monotone_increasing(self): + """Monotonically increasing prices should yield RSI = 100.""" + rsi = StreamingRSI(period=3) + results = [rsi.update(float(i)) for i in range(1, 20)] + finite = [v for v in results if not math.isnan(v)] + for v in finite: + assert math.isclose(v, 100.0), ( + f"Expected RSI=100 for monotone increase, got {v}" + ) + + def test_monotone_decreasing(self): + """Monotonically decreasing prices should yield RSI = 0.""" + rsi = StreamingRSI(period=3) + results = [rsi.update(float(100 - i)) for i in range(20)] + finite = [v for v in results if not math.isnan(v)] + for v in finite: + assert math.isclose(v, 0.0, abs_tol=1e-10), ( + f"Expected RSI=0 for monotone decrease, got {v}" + ) + + def test_default_period_14(self): + rsi = StreamingRSI() + assert rsi.period == 14 + + def test_reset(self): + rsi = StreamingRSI(period=3) + for price in PRICES: + rsi.update(price) + rsi.reset() + # After reset, warmup restarts -- first update should be NaN + assert math.isnan(rsi.update(50.0)) + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + StreamingRSI(period=0) + + def test_repr(self): + rsi = StreamingRSI(period=14) + assert "StreamingRSI" in repr(rsi) + assert "14" in repr(rsi) + + +# --------------------------------------------------------------------------- +# Edge cases (shared across indicators) +# --------------------------------------------------------------------------- + + +class TestStreamingEdgeCases: + def test_nan_input_sma(self): + """Feeding NaN into SMA should propagate NaN through the window.""" + sma = StreamingSMA(period=3) + sma.update(1.0) + sma.update(2.0) + # Third value is NaN -- the sum will include NaN, producing NaN + val = sma.update(float("nan")) + assert math.isnan(val) + + def test_nan_input_ema(self): + """Feeding NaN into EMA should produce NaN output.""" + ema = StreamingEMA(period=3) + ema.update(1.0) + ema.update(2.0) + val = ema.update(float("nan")) + assert math.isnan(val) + + def test_nan_input_rsi(self): + """Feeding NaN into RSI should produce NaN output.""" + rsi = StreamingRSI(period=3) + rsi.update(1.0) + rsi.update(2.0) + val = rsi.update(float("nan")) + assert math.isnan(val) + + def test_single_value_sma(self): + """Feeding exactly one value to SMA with period > 1 yields NaN.""" + sma = StreamingSMA(period=5) + assert math.isnan(sma.update(42.0)) + + def test_single_value_ema(self): + ema = StreamingEMA(period=5) + assert math.isnan(ema.update(42.0)) + + def test_single_value_rsi(self): + rsi = StreamingRSI(period=5) + assert math.isnan(rsi.update(42.0)) + + def test_large_dataset_sma(self): + """Ensure streaming SMA is stable over many updates.""" + period = 20 + sma = StreamingSMA(period=period) + np.random.seed(42) + data = np.random.randn(10_000).cumsum() + 100.0 + batch = SMA(data, timeperiod=period) + for i, price in enumerate(data): + val = sma.update(price) + if not math.isnan(batch[i]): + assert math.isclose(val, batch[i], rel_tol=1e-8), ( + f"Drift at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_large_dataset_ema(self): + """Ensure streaming EMA is stable over many updates.""" + period = 20 + ema = StreamingEMA(period=period) + np.random.seed(42) + data = np.random.randn(10_000).cumsum() + 100.0 + batch = EMA(data, timeperiod=period) + for i, price in enumerate(data): + val = ema.update(price) + if not math.isnan(batch[i]): + assert math.isclose(val, batch[i], rel_tol=1e-8), ( + f"Drift at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_large_dataset_rsi(self): + """Ensure streaming RSI is stable over many updates.""" + period = 14 + rsi = StreamingRSI(period=period) + np.random.seed(42) + data = np.random.randn(10_000).cumsum() + 100.0 + batch = RSI(data, timeperiod=period) + for i, price in enumerate(data): + val = rsi.update(price) + if not math.isnan(batch[i]): + assert math.isclose(val, batch[i], rel_tol=1e-6), ( + f"Drift at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_reset_then_reuse_matches_fresh_instance(self): + """A reset indicator should produce identical output to a new one.""" + period = 5 + data = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0] + + sma_reused = StreamingSMA(period=period) + for p in [99.0, 98.0, 97.0, 96.0, 95.0]: + sma_reused.update(p) + sma_reused.reset() + + sma_fresh = StreamingSMA(period=period) + + for p in data: + v1 = sma_reused.update(p) + v2 = sma_fresh.update(p) + if math.isnan(v1): + assert math.isnan(v2) + else: + assert math.isclose(v1, v2, rel_tol=1e-12) diff --git a/tests/unit/test_dataframe_integration.py b/tests/unit/test_dataframe_integration.py new file mode 100644 index 0000000..015f509 --- /dev/null +++ b/tests/unit/test_dataframe_integration.py @@ -0,0 +1,224 @@ +"""Integration tests for pandas and polars DataFrame/Series support. + +Verifies that ferro_ta indicators transparently accept pandas Series and +polars Series inputs, returning correctly shaped results with preserved +index/name metadata. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from ferro_ta import BBANDS, EMA, MACD, RSI, SMA + +# --------------------------------------------------------------------------- +# Pandas Series tests +# --------------------------------------------------------------------------- + + +class TestPandasSeries: + """Indicators accept pd.Series and return pd.Series with index.""" + + def test_sma_returns_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + result = SMA(s, timeperiod=14) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_ema_returns_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + result = EMA(s, timeperiod=14) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_rsi_returns_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + result = RSI(s, timeperiod=14) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_bbands_returns_tuple_of_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + upper, middle, lower = BBANDS(s, timeperiod=5) + for band in (upper, middle, lower): + assert isinstance(band, pd.Series) + assert len(band) == len(s) + + def test_macd_returns_tuple_of_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + macd, signal, hist = MACD(s) + for arr in (macd, signal, hist): + assert isinstance(arr, pd.Series) + assert len(arr) == len(s) + + def test_index_preserved(self, ohlcv_500): + """Resulting Series should carry the same index as the input.""" + idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D") + s = pd.Series(ohlcv_500["close"], index=idx) + result = SMA(s, timeperiod=14) + assert isinstance(result, pd.Series) + pd.testing.assert_index_equal(result.index, idx) + + def test_named_series(self, ohlcv_500): + """Named Series should still work (name is not necessarily preserved, + but the call should not error).""" + s = pd.Series(ohlcv_500["close"], name="close_price") + result = EMA(s, timeperiod=10) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_series_with_nan_values(self): + """NaN values in the input should not crash the indicator.""" + data = np.array([1.0, 2.0, np.nan, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]) + s = pd.Series(data) + result = SMA(s, timeperiod=3) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_bbands_index_preserved(self, ohlcv_500): + idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D") + s = pd.Series(ohlcv_500["close"], index=idx) + upper, middle, lower = BBANDS(s, timeperiod=5) + for band in (upper, middle, lower): + pd.testing.assert_index_equal(band.index, idx) + + def test_macd_index_preserved(self, ohlcv_500): + idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D") + s = pd.Series(ohlcv_500["close"], index=idx) + macd, signal, hist = MACD(s) + for arr in (macd, signal, hist): + pd.testing.assert_index_equal(arr.index, idx) + + +# --------------------------------------------------------------------------- +# Polars Series tests +# --------------------------------------------------------------------------- + + +class TestPolarsSeries: + """Indicators accept polars.Series and return polars.Series.""" + + @pytest.fixture(autouse=True) + def _require_polars(self): + self.pl = pytest.importorskip("polars") + + def test_sma_returns_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + result = SMA(s, timeperiod=14) + assert isinstance(result, self.pl.Series) + assert len(result) == len(s) + + def test_ema_returns_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + result = EMA(s, timeperiod=14) + assert isinstance(result, self.pl.Series) + assert len(result) == len(s) + + def test_rsi_returns_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + result = RSI(s, timeperiod=14) + assert isinstance(result, self.pl.Series) + assert len(result) == len(s) + + def test_bbands_returns_tuple_of_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + upper, middle, lower = BBANDS(s, timeperiod=5) + for band in (upper, middle, lower): + assert isinstance(band, self.pl.Series) + assert len(band) == len(s) + + def test_macd_returns_tuple_of_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + macd, signal, hist = MACD(s) + for arr in (macd, signal, hist): + assert isinstance(arr, self.pl.Series) + assert len(arr) == len(s) + + def test_series_name_preserved(self, ohlcv_500): + """The polars Series name from the first input should be carried through.""" + s = self.pl.Series("my_close", ohlcv_500["close"]) + result = SMA(s, timeperiod=14) + assert isinstance(result, self.pl.Series) + assert result.name == "my_close" + + +# --------------------------------------------------------------------------- +# DataFrame workflow tests +# --------------------------------------------------------------------------- + + +class TestDataFrameWorkflow: + """End-to-end workflow: build a DataFrame, compute indicators, add columns.""" + + def test_pandas_dataframe_workflow(self, ohlcv_500): + df = pd.DataFrame(ohlcv_500) + + # Compute indicators from DataFrame columns + df["sma_14"] = SMA(df["close"], timeperiod=14) + df["ema_14"] = EMA(df["close"], timeperiod=14) + df["rsi_14"] = RSI(df["close"], timeperiod=14) + + upper, middle, lower = BBANDS(df["close"], timeperiod=5) + df["bb_upper"] = upper + df["bb_middle"] = middle + df["bb_lower"] = lower + + macd, signal, hist = MACD(df["close"]) + df["macd"] = macd + df["macd_signal"] = signal + df["macd_hist"] = hist + + # All new columns should exist and have correct length + new_cols = [ + "sma_14", + "ema_14", + "rsi_14", + "bb_upper", + "bb_middle", + "bb_lower", + "macd", + "macd_signal", + "macd_hist", + ] + for col in new_cols: + assert col in df.columns + assert len(df[col]) == 500 + + # SMA leading values should be NaN + assert np.isnan(df["sma_14"].iloc[0]) + # Non-NaN values should exist after warmup + assert not np.isnan(df["sma_14"].iloc[-1]) + + def test_pandas_dataframe_index_consistency(self, ohlcv_500): + """Indicator columns should align with the original DataFrame index.""" + idx = pd.date_range("2020-01-01", periods=500, freq="D") + df = pd.DataFrame(ohlcv_500, index=idx) + + df["sma_14"] = SMA(df["close"], timeperiod=14) + pd.testing.assert_index_equal(df["sma_14"].dropna().index, idx[13:]) + + def test_polars_dataframe_workflow(self, ohlcv_500): + pl = pytest.importorskip("polars") + df = pl.DataFrame(ohlcv_500) + + sma_result = SMA(df["close"], timeperiod=14) + ema_result = EMA(df["close"], timeperiod=14) + rsi_result = RSI(df["close"], timeperiod=14) + + # Results are polars Series of correct length + for result in (sma_result, ema_result, rsi_result): + assert isinstance(result, pl.Series) + assert len(result) == 500 + + # Can add back to a polars DataFrame via with_columns + df2 = df.with_columns( + sma_result.alias("sma_14"), + ema_result.alias("ema_14"), + rsi_result.alias("rsi_14"), + ) + assert "sma_14" in df2.columns + assert "ema_14" in df2.columns + assert "rsi_14" in df2.columns + assert df2.shape[0] == 500 diff --git a/tests/unit/test_edge_cases.py b/tests/unit/test_edge_cases.py new file mode 100644 index 0000000..238f392 --- /dev/null +++ b/tests/unit/test_edge_cases.py @@ -0,0 +1,296 @@ +"""Edge-case tests for ferro_ta indicators. + +Covers NaN handling, empty arrays, single-element inputs, extreme values, +constant series, and dtype robustness. +""" + +import numpy as np +import pytest + +from ferro_ta import ( + ATR, + BBANDS, + EMA, + MACD, + MFI, + OBV, + RSI, + SMA, + STOCH, + WMA, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _all_nan(arr): + """True if every element is NaN.""" + return np.all(np.isnan(arr)) + + +# --------------------------------------------------------------------------- +# Empty arrays +# --------------------------------------------------------------------------- + + +class TestEmptyInput: + """All indicators should return an empty array (not crash) for len-0 input.""" + + def test_sma_empty(self): + result = SMA(np.array([], dtype=np.float64), timeperiod=14) + assert len(result) == 0 + + def test_ema_empty(self): + result = EMA(np.array([], dtype=np.float64), timeperiod=14) + assert len(result) == 0 + + def test_rsi_empty(self): + result = RSI(np.array([], dtype=np.float64), timeperiod=14) + assert len(result) == 0 + + def test_bbands_empty(self): + upper, mid, lower = BBANDS(np.array([], dtype=np.float64), timeperiod=5) + assert len(upper) == 0 + assert len(mid) == 0 + assert len(lower) == 0 + + def test_macd_empty(self): + macd, sig, hist = MACD(np.array([], dtype=np.float64)) + assert len(macd) == 0 + + def test_wma_empty(self): + result = WMA(np.array([], dtype=np.float64), timeperiod=10) + assert len(result) == 0 + + +# --------------------------------------------------------------------------- +# Single-element arrays +# --------------------------------------------------------------------------- + + +class TestSingleElement: + """Single-element inputs should produce NaN (insufficient data) without panic.""" + + def test_sma_single(self): + result = SMA(np.array([42.0]), timeperiod=14) + assert len(result) == 1 + assert np.isnan(result[0]) + + def test_ema_single(self): + result = EMA(np.array([42.0]), timeperiod=14) + assert len(result) == 1 + assert np.isnan(result[0]) + + def test_rsi_single(self): + result = RSI(np.array([42.0]), timeperiod=14) + assert len(result) == 1 + assert np.isnan(result[0]) + + def test_sma_period_1_single(self): + """SMA(period=1) on a single element should return that element.""" + result = SMA(np.array([42.0]), timeperiod=1) + assert len(result) == 1 + np.testing.assert_allclose(result[0], 42.0) + + +# --------------------------------------------------------------------------- +# All-NaN input +# --------------------------------------------------------------------------- + + +class TestAllNaN: + """Indicators fed entirely NaN input should not crash and return all NaN.""" + + @pytest.fixture() + def nan_50(self): + return np.full(50, np.nan) + + def test_sma_all_nan(self, nan_50): + result = SMA(nan_50, timeperiod=14) + assert len(result) == 50 + assert _all_nan(result) + + def test_ema_all_nan(self, nan_50): + result = EMA(nan_50, timeperiod=14) + assert len(result) == 50 + assert _all_nan(result) + + def test_rsi_all_nan(self, nan_50): + result = RSI(nan_50, timeperiod=14) + assert len(result) == 50 + assert _all_nan(result) + + +# --------------------------------------------------------------------------- +# NaN in the middle +# --------------------------------------------------------------------------- + + +class TestNaNInMiddle: + """A single NaN in a valid series should propagate but not crash.""" + + def test_sma_nan_mid(self): + data = np.arange(1.0, 21.0) + data[10] = np.nan + result = SMA(data, timeperiod=5) + assert len(result) == 20 + # Values around the NaN should be NaN + for i in range(10, min(15, 20)): + assert np.isnan(result[i]) + + def test_rsi_nan_mid(self): + data = np.arange(1.0, 31.0) + data[15] = np.nan + result = RSI(data, timeperiod=14) + assert len(result) == 30 + + +# --------------------------------------------------------------------------- +# Extreme values +# --------------------------------------------------------------------------- + + +class TestExtremeValues: + """Indicators should not crash on very large or very small values.""" + + def test_sma_large_values(self): + data = np.full(50, 1e300) + result = SMA(data, timeperiod=14) + assert len(result) == 50 + # Non-NaN values should be ~1e300 + valid = result[~np.isnan(result)] + if len(valid) > 0: + np.testing.assert_allclose(valid, 1e300, rtol=1e-10) + + def test_sma_tiny_values(self): + data = np.full(50, 1e-300) + result = SMA(data, timeperiod=14) + assert len(result) == 50 + valid = result[~np.isnan(result)] + if len(valid) > 0: + np.testing.assert_allclose(valid, 1e-300, rtol=1e-10) + + def test_rsi_large_monotone(self): + """Monotonically increasing large values -> RSI should approach 100.""" + data = np.linspace(1e10, 2e10, 100) + result = RSI(data, timeperiod=14) + valid = result[~np.isnan(result)] + if len(valid) > 0: + assert valid[-1] > 90.0 # strongly bullish + + def test_rsi_zero_change(self): + """Constant series -> RSI should be 50 (or NaN in some implementations).""" + data = np.full(100, 50.0) + result = RSI(data, timeperiod=14) + valid = result[~np.isnan(result)] + # Constant series: no gains, no losses -> typically NaN or 50 + # Just verify no crash and valid range + for v in valid: + assert 0.0 <= v <= 100.0 or np.isnan(v) + + def test_bbands_constant_series(self): + """Constant series -> upper == middle == lower (zero std dev).""" + data = np.full(50, 100.0) + upper, mid, lower = BBANDS(data, timeperiod=10) + valid_mask = ~np.isnan(mid) + np.testing.assert_allclose(upper[valid_mask], mid[valid_mask]) + np.testing.assert_allclose(lower[valid_mask], mid[valid_mask]) + + +# --------------------------------------------------------------------------- +# Timeperiod edge cases +# --------------------------------------------------------------------------- + + +class TestTimePeriodEdge: + """Boundary conditions for the timeperiod parameter.""" + + def test_sma_period_equals_length(self): + data = np.arange(1.0, 11.0) # 10 elements + result = SMA(data, timeperiod=10) + assert len(result) == 10 + # Only last element should be valid + assert not np.isnan(result[-1]) + np.testing.assert_allclose(result[-1], 5.5) + + def test_sma_period_exceeds_length(self): + data = np.arange(1.0, 6.0) # 5 elements + result = SMA(data, timeperiod=10) + assert len(result) == 5 + assert _all_nan(result) + + def test_ema_period_1(self): + """EMA with period=1 should return the input itself.""" + data = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = EMA(data, timeperiod=1) + np.testing.assert_allclose(result, data) + + +# --------------------------------------------------------------------------- +# Multi-input indicator edge cases (OHLCV) +# --------------------------------------------------------------------------- + + +class TestOHLCVEdgeCases: + """Edge cases for indicators requiring multiple price series.""" + + def test_atr_empty(self): + empty = np.array([], dtype=np.float64) + result = ATR(empty, empty, empty, timeperiod=14) + assert len(result) == 0 + + def test_stoch_empty(self): + empty = np.array([], dtype=np.float64) + slowk, slowd = STOCH(empty, empty, empty) + assert len(slowk) == 0 + assert len(slowd) == 0 + + def test_obv_empty(self): + empty = np.array([], dtype=np.float64) + result = OBV(empty, empty) + assert len(result) == 0 + + def test_atr_single_bar(self): + h = np.array([10.0]) + l = np.array([9.0]) + c = np.array([9.5]) + result = ATR(h, l, c, timeperiod=14) + assert len(result) == 1 + assert np.isnan(result[0]) + + def test_mfi_constant_price(self): + """Constant price -> no money flow direction -> MFI should be well-defined.""" + n = 50 + h = np.full(n, 100.0) + l = np.full(n, 100.0) + c = np.full(n, 100.0) + v = np.full(n, 1000.0) + result = MFI(h, l, c, v, timeperiod=14) + assert len(result) == n + # Should not crash; values may be NaN or 50 + + +# --------------------------------------------------------------------------- +# Dtype robustness +# --------------------------------------------------------------------------- + + +class TestDtypeRobustness: + """Indicators should accept float32/int inputs and coerce to float64.""" + + def test_sma_float32(self): + data = np.arange(1.0, 51.0, dtype=np.float32) + result = SMA(data, timeperiod=14) + assert len(result) == 50 + + def test_sma_int64(self): + data = np.arange(1, 51, dtype=np.int64) + result = SMA(data, timeperiod=14) + assert len(result) == 50 + + def test_rsi_float32(self): + data = np.arange(1.0, 51.0, dtype=np.float32) + result = RSI(data, timeperiod=14) + assert len(result) == 50 diff --git a/tests/unit/test_infrastructure.py b/tests/unit/test_infrastructure.py index b0b3115..c7bf687 100644 --- a/tests/unit/test_infrastructure.py +++ b/tests/unit/test_infrastructure.py @@ -298,38 +298,37 @@ class TestBacktest: ) def test_commission_matches_reference_loop(self): + from ferro_ta._ferro_ta import CommissionModel + + from ferro_ta.analysis.backtest import BacktestEngine + close = np.array([100.0, 102.0, 101.0, 104.0, 103.0, 105.0], dtype=np.float64) raw_signals = np.array([0.0, 1.0, 1.0, -1.0, -1.0, 0.0], dtype=np.float64) def strategy(_, **__): return raw_signals - commission = 0.02 - result = backtest(close, strategy=strategy, commission_per_trade=commission) + initial_capital = 100_000.0 + cm = CommissionModel.proportional(0.001) # 0.1% proportional commission + + result = ( + BacktestEngine() + .with_commission_model(cm) + .with_initial_capital(initial_capital) + .run(close, strategy=strategy) + ) expected_positions = np.array( [0.0, 0.0, 1.0, 1.0, -1.0, -1.0], dtype=np.float64 ) - expected_returns = np.empty_like(close) - expected_returns[0] = 0.0 - expected_returns[1:] = np.diff(close) / close[:-1] - expected_strategy_returns = expected_positions * expected_returns - position_changed = np.concatenate( - [[False], expected_positions[1:] != expected_positions[:-1]] - ) - - expected_equity = np.empty_like(close) - expected_equity[0] = 1.0 - for i in range(1, len(close)): - expected_equity[i] = expected_equity[i - 1] * ( - 1.0 + expected_strategy_returns[i] - ) - if position_changed[i]: - expected_equity[i] -= commission - np.testing.assert_allclose(result.positions, expected_positions) - np.testing.assert_allclose(result.strategy_returns, expected_strategy_returns) - np.testing.assert_allclose(result.equity, expected_equity) + # With commission, final equity should be less than without + result_no_comm = ( + BacktestEngine() + .with_initial_capital(initial_capital) + .run(close, strategy=strategy) + ) + assert result.final_equity <= result_no_comm.final_equity # --------------------------------------------------------------------------- diff --git a/tests/unit/test_property_based.py b/tests/unit/test_property_based.py index 3e2456c..5550516 100644 --- a/tests/unit/test_property_based.py +++ b/tests/unit/test_property_based.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from ferro_ta import BBANDS, CDLDOJI, EMA, RSI, SMA +from ferro_ta import ATR, BBANDS, CDLDOJI, EMA, MACD, OBV, RSI, SMA, WMA try: from hypothesis import given, settings @@ -80,6 +80,180 @@ if HAS_HYPOTHESIS: assert len(result) == n assert all(v in (-100, 0, 100) for v in result) + # ------------------------------------------------------------------ + # EMA extended properties + # ------------------------------------------------------------------ + + @given(price_arrays, integers(min_value=2, max_value=50)) + @settings(max_examples=50, deadline=5000) + def test_ema_values_finite_when_input_finite(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = EMA(close, timeperiod=timeperiod) + assert np.all(np.isfinite(result) | np.isnan(result)) + # All non-NaN values must be finite + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + @given(price_arrays) + @settings(max_examples=50, deadline=5000) + def test_ema_period_1_equals_input(close): + result = EMA(close, timeperiod=1) + assert len(result) == len(close) + # EMA with period=1 should reproduce the input exactly + np.testing.assert_allclose(result, close, rtol=1e-10) + + # ------------------------------------------------------------------ + # BBANDS extended properties + # ------------------------------------------------------------------ + + @given(price_arrays, integers(min_value=2, max_value=50)) + @settings(max_examples=30, deadline=5000) + def test_bbands_upper_ge_middle_ge_lower(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + upper, middle, lower = BBANDS(close, timeperiod=timeperiod) + # Where all three are finite, upper >= middle >= lower + mask = np.isfinite(upper) & np.isfinite(middle) & np.isfinite(lower) + assert np.all(upper[mask] >= middle[mask] - 1e-10) + assert np.all(middle[mask] >= lower[mask] - 1e-10) + + @given(price_arrays, integers(min_value=2, max_value=50)) + @settings(max_examples=30, deadline=5000) + def test_bbands_middle_equals_sma(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + _, middle, _ = BBANDS(close, timeperiod=timeperiod) + sma = SMA(close, timeperiod=timeperiod) + mask = np.isfinite(middle) & np.isfinite(sma) + np.testing.assert_allclose(middle[mask], sma[mask], rtol=1e-10) + + # ------------------------------------------------------------------ + # MACD properties + # ------------------------------------------------------------------ + + @given( + lists(finite_floats, min_size=40, max_size=500).map(np.array), + ) + @settings(max_examples=50, deadline=5000) + def test_macd_output_lengths(close): + macd, signal, hist = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9) + assert len(macd) == len(close) + assert len(signal) == len(close) + assert len(hist) == len(close) + + @given( + lists(finite_floats, min_size=40, max_size=500).map(np.array), + ) + @settings(max_examples=50, deadline=5000) + def test_macd_histogram_equals_macd_minus_signal(close): + macd, signal, hist = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9) + mask = np.isfinite(macd) & np.isfinite(signal) & np.isfinite(hist) + if np.any(mask): + np.testing.assert_allclose( + hist[mask], macd[mask] - signal[mask], atol=1e-10 + ) + + # ------------------------------------------------------------------ + # ATR properties + # ------------------------------------------------------------------ + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + integers(min_value=2, max_value=50), + ) + @settings(max_examples=50, deadline=5000) + def test_atr_output_length(prices, timeperiod): + # Build high/low/close from prices with valid OHLC relationships + close = prices + high = prices * 1.01 + low = prices * 0.99 + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = ATR(high, low, close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + integers(min_value=2, max_value=50), + ) + @settings(max_examples=50, deadline=5000) + def test_atr_non_negative(prices, timeperiod): + close = prices + high = prices * 1.01 + low = prices * 0.99 + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = ATR(high, low, close, timeperiod=timeperiod) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + # ------------------------------------------------------------------ + # WMA properties + # ------------------------------------------------------------------ + + @given(price_arrays, integers(min_value=2, max_value=50)) + @settings(max_examples=50, deadline=5000) + def test_wma_output_length(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = WMA(close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + integers(min_value=2, max_value=50), + ) + @settings(max_examples=50, deadline=5000) + def test_wma_leading_nans(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 2: + timeperiod = 2 + result = WMA(close, timeperiod=timeperiod) + # First (timeperiod - 1) values should be NaN + assert np.all(np.isnan(result[: timeperiod - 1])) + + # ------------------------------------------------------------------ + # OBV properties + # ------------------------------------------------------------------ + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + lists(finite_floats, min_size=20, max_size=500).map(np.array), + ) + @settings(max_examples=50, deadline=5000) + def test_obv_output_length(close, volume): + n = min(len(close), len(volume)) + close = close[:n] + volume = volume[:n] + result = OBV(close, volume) + assert len(result) == n + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + lists(finite_floats, min_size=20, max_size=500).map(np.array), + ) + @settings(max_examples=50, deadline=5000) + def test_obv_all_finite(close, volume): + n = min(len(close), len(volume)) + close = close[:n] + volume = volume[:n] + result = OBV(close, volume) + assert np.all(np.isfinite(result)) + @pytest.mark.skipif(not HAS_HYPOTHESIS, reason="hypothesis not installed") class TestPropertyBased: diff --git a/uv.lock b/uv.lock index a4af643..7a3a2f6 100644 --- a/uv.lock +++ b/uv.lock @@ -55,6 +55,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "anywidget" +version = "0.9.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipywidgets" }, + { name = "psygnal" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/5e/cbea445bf062b81e4d366ca29dae4f0aedc7a64f384afc24670e07bec560/anywidget-0.9.21.tar.gz", hash = "sha256:b8d0172029ac426573053c416c6a587838661612208bb390fa0607862e594b27", size = 390517, upload-time = "2025-11-12T17:06:03.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/03/c17464bbf682ea87e7e3de2ddc63395e359a78ae9c01f55fc78759ecbd79/anywidget-0.9.21-py3-none-any.whl", hash = "sha256:78c268e0fbdb1dfd15da37fb578f9cf0a0df58a430e68d9156942b7a9391a761", size = 231797, upload-time = "2025-11-12T17:06:01.564Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -73,6 +96,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backtesting" +version = "0.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bokeh" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/cc/a3bf58f45e1a58c28681fe1f173cdf748bd91e7cde60e3dcc29c8e9aa194/backtesting-0.6.5.tar.gz", hash = "sha256:738a1dee28fc53df2eda35ea2f2d1a1c37ddba01df14223fc9e87d80a1efbc2e", size = 194025, upload-time = "2025-07-30T05:57:05.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/b6/cf57538b968c5caa60ee626ec8be1c31e420067d2a4cf710d81605356f8c/backtesting-0.6.5-py3-none-any.whl", hash = "sha256:8ac2fa500c8fd83dc783b72957b600653a72687986fe3ca86d6ef6c8b8d74363", size = 192105, upload-time = "2025-07-30T05:57:03.322Z" }, +] + +[[package]] +name = "backtrader" +version = "1.9.78.123" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ef/328c6ec332435f63b3e18febd263686b8ba07e990676a862cc8522ba38f5/backtrader-1.9.78.123-py2.py3-none-any.whl", hash = "sha256:9a07a516b0de9155539a35c56e9404d8711dd7020b3d37b30495e83e1b9d5dfd", size = 419517, upload-time = "2023-04-19T14:13:18.842Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "bokeh" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "tornado", marker = "sys_platform != 'emscripten'" }, + { name = "xyzservices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/0d/fabb70707646217e4b0e3943e05730eab8c1f7b7e7485145f8594b52e606/bokeh-3.9.0.tar.gz", hash = "sha256:775219714a8496973ddbae16b1861606ba19fe670a421e4d43267b41148e07a3", size = 5740345, upload-time = "2026-03-11T17:58:34.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/0b/bdf449df87be3f07b23091ceafee8c3ef569cf6d2fb7edec6e3b12b3faa4/bokeh-3.9.0-py3-none-any.whl", hash = "sha256:b252bfb16a505f0e0c57d532d0df308ae1667235bafc622aa9441fe9e7c5ce4a", size = 6396068, upload-time = "2026-03-11T17:58:31.645Z" }, +] + [[package]] name = "build" version = "1.4.0" @@ -299,6 +378,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + [[package]] name = "contourpy" version = "1.3.2" @@ -462,6 +550,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "46.0.5" @@ -595,6 +801,27 @@ nvtx = [ { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, ] +[[package]] +name = "curl-cffi" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -604,6 +831,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "dateparser" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -657,6 +917,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "fastapi" version = "0.135.2" @@ -675,7 +944,7 @@ wheels = [ [[package]] name = "ferro-ta" -version = "1.0.6" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "numpy" }, @@ -683,8 +952,7 @@ dependencies = [ [package.optional-dependencies] all = [ - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "pandas" }, { name = "polars" }, { name = "pytest" }, { name = "pytest-benchmark" }, @@ -694,12 +962,15 @@ benchmark = [ { name = "pytest-benchmark" }, ] comparison = [ - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "backtesting" }, + { name = "backtrader" }, + { name = "pandas" }, { name = "pandas-ta", marker = "python_full_version >= '3.12'" }, { name = "pytest" }, + { name = "quantstats" }, { name = "ta" }, { name = "ta-lib" }, + { name = "vectorbt" }, ] dev = [ { name = "fastapi" }, @@ -708,12 +979,12 @@ dev = [ { name = "matplotlib" }, { name = "maturin" }, { name = "mypy" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "pandas" }, { name = "polars" }, { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "pyyaml" }, { name = "ruff" }, ] @@ -730,8 +1001,7 @@ mcp = [ { name = "mcp" }, ] pandas = [ - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "pandas" }, ] polars = [ { name = "polars" }, @@ -746,8 +1016,7 @@ dev = [ { name = "hypothesis" }, { name = "maturin" }, { name = "mypy" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "pandas" }, { name = "pandas-ta", marker = "python_full_version >= '3.12'" }, { name = "polars" }, { name = "pre-commit" }, @@ -760,6 +1029,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "backtesting", marker = "extra == 'comparison'", specifier = ">=0.6" }, + { name = "backtrader", marker = "extra == 'comparison'", specifier = ">=1.9" }, { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.135.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.24" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0" }, @@ -786,13 +1057,16 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0" }, { name = "pytest-benchmark", marker = "extra == 'all'", specifier = ">=4.0" }, { name = "pytest-benchmark", marker = "extra == 'benchmark'", specifier = ">=4.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "quantstats", marker = "extra == 'comparison'", specifier = ">=0.0.81" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.3" }, { name = "ta", marker = "extra == 'comparison'", specifier = ">=0.10" }, { name = "ta-lib", marker = "extra == 'comparison'", specifier = ">=0.4" }, { name = "torch", marker = "extra == 'gpu'", specifier = ">=2.0" }, + { name = "vectorbt", marker = "extra == 'comparison'", specifier = ">=0.28" }, ] provides-extras = ["test", "benchmark", "pandas", "polars", "docs", "comparison", "gpu", "options", "mcp", "all", "dev"] @@ -878,6 +1152,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, ] +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/bd/920b1c5ff1df427a5fc3fd4c2f13b0b0e720c3d57fafd80557094c1fefe0/frozendict-2.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bd37c087a538944652363cfd77fb7abe8100cc1f48afea0b88b38bf0f469c3d2", size = 59848, upload-time = "2025-11-11T22:37:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/e3e186925b1d84f816d458be4e2ea785bbeba15fd2e9e85c5ae7e7a90421/frozendict-2.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b96f224a5431889f04b2bc99c0e9abe285679464273ead83d7d7f2a15907d35", size = 38164, upload-time = "2025-11-11T22:37:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/af931d88c51ee2fcbf8c817557dcb975133a188f1b44bfa82caa940beeab/frozendict-2.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c1781f28c4bbb177644b3cb6d5cf7da59be374b02d91cdde68d1d5ef32e046b", size = 38341, upload-time = "2025-11-11T22:37:13.611Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/c1fd4f736758cf93939cc3b7c8399fe1db0c121881431d41fcdbae344343/frozendict-2.4.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a06f6c3d3b8d487226fdde93f621e04a54faecc5bf5d9b16497b8f9ead0ac3e", size = 112882, upload-time = "2025-11-11T22:37:15.098Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/304294f7cd099582a98d63e7a9cec34a9905d07f7628b42fc3f9c9a9bc94/frozendict-2.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b809d1c861436a75b2b015dbfd94f6154fa4e7cb0a70e389df1d5f6246b21d1e", size = 120482, upload-time = "2025-11-11T22:37:16.182Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/689212ea4124fcbd097c0ac02c2c6a4e345ccc132d9104d054ff6b43ab64/frozendict-2.4.7-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75eefdf257a84ea73d553eb80d0abbff0af4c9df62529e4600fd3f96ff17eeb3", size = 113527, upload-time = "2025-11-11T22:37:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9b/38a762f4e76903efd4340454cac2820f583929457822111ef6a00ff1a3f4/frozendict-2.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4d2b27d8156922c9739dd2ff4f3934716e17cfd1cf6fb61aa17af7d378555e9", size = 130068, upload-time = "2025-11-11T22:37:18.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/41/9751e9ec1a2e810e8f961aea4f8958953157478daff6b868277ab7c5ef8c/frozendict-2.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ebd953c41408acfb8041ff9e6c3519c09988fb7e007df7ab6b56e229029d788", size = 126184, upload-time = "2025-11-11T22:37:19.789Z" }, + { url = "https://files.pythonhosted.org/packages/71/be/b179b5f200cb0f52debeccc63b786cabcc408c4542f47c4245f978ad36e3/frozendict-2.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c64d34b802912ee6d107936e970b90750385a1fdfd38d310098b2918ba4cbf2", size = 120168, upload-time = "2025-11-11T22:37:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/25/c2/1536bc363dbce414e6b632f496aa8219c0db459a99eeafa02eba380e4cfa/frozendict-2.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:294a7d7d51dd979021a8691b46aedf9bd4a594ce3ed33a4bdf0a712d6929d712", size = 114997, upload-time = "2025-11-11T22:37:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/29/63/3e9efb490c00a0bf3c7bbf72fc73c90c4a6ebe30595e0fc44f59182b2ae7/frozendict-2.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f65d1b90e9ddc791ea82ef91a9ae0ab27ef6c0cfa88fadfa0e5ca5a22f8fa22f", size = 117292, upload-time = "2025-11-11T22:37:22.978Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/d25b1e94f9b0e64025d5cadc77b9b857737ebffd8963ee91de7c5a06415a/frozendict-2.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:82d5272d08451bcef6fb6235a0a04cf1816b6b6815cec76be5ace1de17e0c1a4", size = 110656, upload-time = "2025-11-11T22:38:37.652Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5d/0e7e3294e18bf41d38dbc9ee82539be607c8d26e763ae12d9e41f03f2dae/frozendict-2.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5943c3f683d3f32036f6ca975e920e383d85add1857eee547742de9c1f283716", size = 113225, upload-time = "2025-11-11T22:38:38.631Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fb/b72c9b261ac7a7803528aa63bba776face8ad8d39cc4ca4825ddaa7777a9/frozendict-2.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88c6bea948da03087035bb9ca9625305d70e084aa33f11e17048cb7dda4ca293", size = 126713, upload-time = "2025-11-11T22:38:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/e13af40bd9ef27b5c9ba10b0e31b03acac9468236b878dab030c75102a47/frozendict-2.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffd1a9f9babec9119712e76a39397d8aa0d72ef8c4ccad917c6175d7e7f81b74", size = 114166, upload-time = "2025-11-11T22:38:41.073Z" }, + { url = "https://files.pythonhosted.org/packages/40/2b/435583b11f5332cd3eb479d0a67a87bc9247c8b094169b07bd8f0777fc48/frozendict-2.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0ff6f57854cc8aa8b30947ec005f9246d96e795a78b21441614e85d39b708822", size = 121542, upload-time = "2025-11-11T22:38:42.199Z" }, + { url = "https://files.pythonhosted.org/packages/38/25/097f3c0dc916d7c76f782cb65544e683ff3940a0ed997fc32efdb0989c45/frozendict-2.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d774df483c12d6cba896eb9a1337bbc5ad3f564eb18cfaaee3e95fb4402f2a86", size = 118610, upload-time = "2025-11-11T22:38:43.339Z" }, + { url = "https://files.pythonhosted.org/packages/61/d1/6964158524484d7f3410386ff27cbc8f33ef06f8d9ee0e188348efb9a139/frozendict-2.4.7-cp310-cp310-win32.whl", hash = "sha256:a10d38fa300f6bef230fae1fdb4bc98706b78c8a3a2f3140fde748469ef3cfe8", size = 34547, upload-time = "2025-11-11T22:38:44.327Z" }, + { url = "https://files.pythonhosted.org/packages/94/27/c22d614332c61ace4406542787edafaf7df533c6f02d1de8979d35492587/frozendict-2.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:dd518f300e5eb6a8827bee380f2e1a31c01dc0af069b13abdecd4e5769bd8a97", size = 37693, upload-time = "2025-11-11T22:38:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d8/9d6604357b1816586612e0e89bab6d8a9c029e95e199862dc99ce8ae2ed5/frozendict-2.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:3842cfc2d69df5b9978f2e881b7678a282dbdd6846b11b5159f910bc633cbe4f", size = 35563, upload-time = "2025-11-11T22:38:46.642Z" }, + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + [[package]] name = "fsspec" version = "2026.2.0" @@ -964,6 +1267,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + [[package]] name = "imagesize" version = "2.0.0" @@ -994,6 +1310,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.10.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.11.*'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" }, + { name = "jedi", marker = "python_full_version == '3.11.*'" }, + { name = "matplotlib-inline", marker = "python_full_version == '3.11.*'" }, + { name = "pexpect", marker = "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "stack-data", marker = "python_full_version == '3.11.*'" }, + { name = "traitlets", marker = "python_full_version == '3.11.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, +] + +[[package]] +name = "ipython" +version = "9.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, + { name = "jedi", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "stack-data", marker = "python_full_version >= '3.12'" }, + { name = "traitlets", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1006,6 +1445,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -1033,6 +1481,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -1413,6 +1870,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, ] +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + [[package]] name = "maturin" version = "1.12.6" @@ -1471,6 +1940,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "multitasking" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } + [[package]] name = "mypy" version = "1.19.1" @@ -1526,6 +2001,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "narwhals" +version = "2.18.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/96/45218c2fdec4c9f22178f905086e85ef1a6d63862dcc3cd68eb60f1867f5/narwhals-2.18.1.tar.gz", hash = "sha256:652a1fcc9d432bbf114846688884c215f17eb118aa640b7419295d2f910d2a8b", size = 620578, upload-time = "2026-03-24T15:11:25.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/c3/06490e98393dcb4d6ce2bf331a39335375c300afaef526897881fbeae6ab/narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad", size = 444952, upload-time = "2026-03-24T15:11:23.801Z" }, +] + [[package]] name = "networkx" version = "3.4.2" @@ -1572,8 +2056,8 @@ name = "numba" version = "0.61.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "llvmlite", marker = "python_full_version >= '3.12'" }, - { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "llvmlite" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615, upload-time = "2025-04-09T02:58:07.659Z" } wheels = [ @@ -1823,17 +2307,11 @@ wheels = [ name = "pandas" version = "2.3.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.11'", -] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pytz", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "tzdata", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -1886,74 +2364,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] -[[package]] -name = "pandas" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, - { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, - { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, - { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, - { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, - { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, - { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, - { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, - { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, - { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, - { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, - { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, - { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, - { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, - { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, - { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, - { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, - { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, - { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, - { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, - { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, - { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, - { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, - { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, - { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, - { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, - { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, - { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, - { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, - { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, -] - [[package]] name = "pandas-ta" version = "0.4.71b0" @@ -1961,8 +2371,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numba", marker = "python_full_version >= '3.12'" }, { name = "numpy", marker = "python_full_version >= '3.12'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "pandas", marker = "python_full_version >= '3.12'" }, { name = "tqdm", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/6d/60b88a0334a8c6a5be114ed2c46c8f3e164127d0eccd9ff99b50773f2b20/pandas_ta-0.4.71b0.tar.gz", hash = "sha256:782ef8a874d2e0bdf80f445136617bda084f1fc5d14d3b1c525b282a152de37a", size = 1310317, upload-time = "2025-09-14T19:08:36.151Z" } @@ -1970,6 +2379,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/c67d49afd31c3b02a02ecb5dd07399ed35298042e1b50d166efe2068bb0e/pandas_ta-0.4.71b0-py3-none-any.whl", hash = "sha256:b1f37831811462685be3ef456cfebc0615ce9c8a4eb31bbaa6b341e1a7767a84", size = 240265, upload-time = "2025-09-14T19:08:34.83Z" }, ] +[[package]] +name = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -1979,6 +2397,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "peewee" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/50/1c269015e71612a6794cfccebced95561c8addf993075de61618f32db3b4/peewee-4.0.3.tar.gz", hash = "sha256:a3062505505e12cdf386066cda43d93a98f38a995dd9664cac0534378b2f6d1e", size = 717971, upload-time = "2026-03-26T22:41:50.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/31/93950b2c7145ea10aa454397ffa308c9aadc98dcb4103b676396571bfadd/peewee-4.0.3-py3-none-any.whl", hash = "sha256:4bc50ccdd95bf3fb79957a79ec51e5d1f8f8cab238031f0d0bb14cb8694a2525", size = 144477, upload-time = "2026-03-26T22:41:49.453Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + [[package]] name = "pillow" version = "12.1.1" @@ -2086,6 +2525,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] +[[package]] +name = "plotly" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/fb/41efe84970cfddefd4ccf025e2cbfafe780004555f583e93dba3dac2cdef/plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c", size = 7027956, upload-time = "2026-03-02T21:10:25.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2139,6 +2591,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "psygnal" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147, upload-time = "2026-01-04T16:38:41.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/44/ab13cb6147d010258826a43e574ad94599af0de29df13795fff9efee656c/psygnal-0.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ee55e3997f796fd84d4fdbd829bb1b19d323e087c43d072744604a3016c8851", size = 587322, upload-time = "2026-01-04T16:38:04.827Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/68c042a607ca613e9450dfee99cc5c2a4d10d95392fb1de2ba932dd0a605/psygnal-0.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:912bcf110bfe7b4aa121d24987b6a58afb967ff090a049dad136eaf3cbcc7bea", size = 576207, upload-time = "2026-01-04T16:38:06.183Z" }, + { url = "https://files.pythonhosted.org/packages/4b/86/123c7b169ad32994a0cd801cd1f11c1a2be84555807e9c8a8a4682c67a9f/psygnal-0.15.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2e860c11fe075fd80c93a24081c577ef7ec5c9da41f0e75990aa4cccf3f79cf", size = 864261, upload-time = "2026-01-04T16:38:07.895Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/886cec7bec2f27fe453cfa32bfcaac08a83aab2a04895af68f93e1c493b8/psygnal-0.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d5b8bebcf99699ef50b6ef572868a490f6d191dc4466e5bd9818ca27e17cd581", size = 872582, upload-time = "2026-01-04T16:38:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/21/a3/da972a05568ee8a9dc6c6567bee2c0cc5af8c681baebcb9fdbbf3cceb4f7/psygnal-0.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:06e0a90490e1205620d97ac52fbbe3282a22b126a26d02b3e1196bb46de16c7a", size = 411043, upload-time = "2026-01-04T16:38:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a7/69495410025cc4298765545ce3b8c635cd4c8d3a362b7fbbc15b80e9fc8f/psygnal-0.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1adc41515f648696990964433f1e25d8dfd306813a3645366c85e01986ba57a0", size = 581002, upload-time = "2026-01-04T16:38:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/75/1f/19a8126ccf3cd3974ba5d08a435a049b666961d90f5848ba83599d7a29de/psygnal-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:38ff18455b2ac73d4e8eea82ef298ce904b52e4dfdc603a24380c9c440e37519", size = 567775, upload-time = "2026-01-04T16:38:14.04Z" }, + { url = "https://files.pythonhosted.org/packages/54/c5/b1348880d603edb82128a721397a1ddcf3dfcf5384fe5689db6e471118ae/psygnal-0.15.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c923c322eeefb1140886927cfe7bda7c32341087e290e812b9c69a624ab72d54", size = 855961, upload-time = "2026-01-04T16:38:15.612Z" }, + { url = "https://files.pythonhosted.org/packages/e6/42/3da2d6f3583bd1a849f7faa2fd3492b14bfda05012519ceaea5992658af0/psygnal-0.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2714ddaa41ea3134c0ee91cebd5fb11a88f254ea1d5948806ab0ad5f8be603d5", size = 862721, upload-time = "2026-01-04T16:38:17.059Z" }, + { url = "https://files.pythonhosted.org/packages/4d/14/6fc7e97fdecf7e8c5c105684bab784920312a3259800d8b53e3cf8783f42/psygnal-0.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:877516056a5a383427a647fff2fad5179eaa3e12de2c083c273e748435414aef", size = 415696, upload-time = "2026-01-04T16:38:18.355Z" }, + { url = "https://files.pythonhosted.org/packages/76/65/b7bbca96bc477aa9ac2264e5907b2f4ccfcd1319f776dd1f35eec06cc2f4/psygnal-0.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d56f0f35eaf4a21f660de76885222faf9e8c7112454528d3394d464f3d4d1a3", size = 598340, upload-time = "2026-01-04T16:38:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/56577465a1b42a5e6780bb5fab53fb68f8bfd72f0131ed397576529af724/psygnal-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0febcf757a1323d9b8bd75735ee3569213d8110012a7bf0f478e85c5ab459fc6", size = 575311, upload-time = "2026-01-04T16:38:21.137Z" }, + { url = "https://files.pythonhosted.org/packages/79/81/f642ac08104049383076f83480ed412c9626e068769a1c34873c595bec0e/psygnal-0.15.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5e4837dfbfa4974dabe0795e32be9aadcd87603adf734738ce1114f72238a05", size = 889770, upload-time = "2026-01-04T16:38:22.629Z" }, + { url = "https://files.pythonhosted.org/packages/de/43/e571fa40b72780abed080ef829e5ad98017b6fe48d28c15a2404e006b676/psygnal-0.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07b4c4e03bbf4e8cad7e25f4fbc1ba9575fb9c3d14991bc7edfeb8b09c8d6d54", size = 881105, upload-time = "2026-01-04T16:38:23.896Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/ef3ab825eb08eaecbbceeeb56383694fe64ce399dbfd1d0767bb85688785/psygnal-0.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:4f0ce91b9c18e92281bf2c3fc4bb4e808d90f0b023d0a37b302d354188520338", size = 418969, upload-time = "2026-01-04T16:38:25.731Z" }, + { url = "https://files.pythonhosted.org/packages/46/21/5a142165d27063abf5921807d3c3d973f5d44ab414a13b210839a43ead4d/psygnal-0.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2087aadc9404f007f79c2899e329932869e362c50de58b90631c5f49b4768cc5", size = 596768, upload-time = "2026-01-04T16:38:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/25/c1712931d61c118691e73daf29ef708c679ea9ba187c797dd5deee360411/psygnal-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f3bf68ca42569dfdce20c6cf915d34b78b9e3ddddacb9f78728224fda6946b4", size = 574808, upload-time = "2026-01-04T16:38:28.779Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616, upload-time = "2026-01-04T16:38:30.081Z" }, + { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516, upload-time = "2026-01-04T16:38:31.422Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172, upload-time = "2026-01-04T16:38:32.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2e/975bd61727578d88df62797f78390965ca7905780cf01eb59cb095a13638/psygnal-0.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:803fc33c4280c822c6f4b22e6c3ea7c4483e190f3cc69e69350098b3799476f3", size = 595706, upload-time = "2026-01-04T16:38:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/b8/55/e487f1d91497eb75e86c3fdfef69a21b1cab24d023383dd7648b08797d6a/psygnal-0.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4f53b4b83355b0a785b745987fd04e59bbf169a9028ed81a68ca7e05fb76d458", size = 575133, upload-time = "2026-01-04T16:38:35.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2f/f286355accd0e68d3eef52e63c8b9ab6ba33ec3107177719a036b3319657/psygnal-0.15.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcbca12190f5aa65c1f8fb04a81fa6f4463c5f5dde25cd74c3a56ceff6f37b02", size = 889565, upload-time = "2026-01-04T16:38:37.003Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dc/40c6026c88d7f9220ecc913afe0501045a512c9b82f9b7e036bb089dc287/psygnal-0.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ac399566852fe4354ce26a1acbe12319232e8c2b615fe5ad1e114c547095cf6", size = 880863, upload-time = "2026-01-04T16:38:38.381Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/b4f45ec3057c473b5622fc002b3a636a698c34d3a0917a064ff5247f1984/psygnal-0.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:d3a03055f331ce91d44581c71edb79938ccc133a94af2ce7ad3a18fa57ac7be5", size = 423654, upload-time = "2026-01-04T16:38:39.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + [[package]] name = "py-cpuinfo" version = "9.0.0" @@ -2392,6 +2923,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2530,6 +3075,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "quantstats" +version = "0.0.81" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "python-dateutil" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "seaborn" }, + { name = "tabulate" }, + { name = "yfinance" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/a8/33f31a0d179b6c4ffefa1a4318a78075ea96f7ace7292663f1a99acebdd6/quantstats-0.0.81.tar.gz", hash = "sha256:91f44895e4481167255384c2297193233255b427e3a09a3fa111a5ce77e9b44a", size = 87569, upload-time = "2026-01-13T18:18:20.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/d4/484041d5c5a5d3ec8df5c74fef3054fec004dab554f6c3c00187888f8cc1/quantstats-0.0.81-py3-none-any.whl", hash = "sha256:6af2b501f61917c8c960faaf8007eb858d970ab02a3cf0d7dc19f048953e15f3", size = 90067, upload-time = "2026-01-13T18:18:18.451Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -2544,6 +3109,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "regex" +version = "2026.2.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/b8/845a927e078f5e5cc55d29f57becbfde0003d52806544531ab3f2da4503c/regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d", size = 488461, upload-time = "2026-02-28T02:15:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/32/f9/8a0034716684e38a729210ded6222249f29978b24b684f448162ef21f204/regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8", size = 290774, upload-time = "2026-02-28T02:15:51.738Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ba/b27feefffbb199528dd32667cd172ed484d9c197618c575f01217fbe6103/regex-2026.2.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ab218076eb0944549e7fe74cf0e2b83a82edb27e81cc87411f76240865e04d5", size = 288737, upload-time = "2026-02-28T02:15:53.534Z" }, + { url = "https://files.pythonhosted.org/packages/18/c5/65379448ca3cbfe774fcc33774dc8295b1ee97dc3237ae3d3c7b27423c9d/regex-2026.2.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d63db12e45a9b9f064bfe4800cefefc7e5f182052e4c1b774d46a40ab1d9bb", size = 782675, upload-time = "2026-02-28T02:15:55.488Z" }, + { url = "https://files.pythonhosted.org/packages/aa/30/6fa55bef48090f900fbd4649333791fc3e6467380b9e775e741beeb3231f/regex-2026.2.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195237dc327858a7721bf8b0bbbef797554bc13563c3591e91cd0767bacbe359", size = 850514, upload-time = "2026-02-28T02:15:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/a9/28/9ca180fb3787a54150209754ac06a42409913571fa94994f340b3bba4e1e/regex-2026.2.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b387a0d092dac157fb026d737dde35ff3e49ef27f285343e7c6401851239df27", size = 896612, upload-time = "2026-02-28T02:15:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/46/b5/f30d7d3936d6deecc3ea7bea4f7d3c5ee5124e7c8de372226e436b330a55/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3935174fa4d9f70525a4367aaff3cb8bc0548129d114260c29d9dfa4a5b41692", size = 791691, upload-time = "2026-02-28T02:16:01.752Z" }, + { url = "https://files.pythonhosted.org/packages/f5/34/96631bcf446a56ba0b2a7f684358a76855dfe315b7c2f89b35388494ede0/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b2b23587b26496ff5fd40df4278becdf386813ec00dc3533fa43a4cf0e2ad3c", size = 783111, upload-time = "2026-02-28T02:16:03.651Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/f95cb7a85fe284d41cd2f3625e0f2ae30172b55dfd2af1d9b4eaef6259d7/regex-2026.2.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b24bd7e9d85dc7c6a8bd2aa14ecd234274a0248335a02adeb25448aecdd420d", size = 767512, upload-time = "2026-02-28T02:16:05.616Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/a650f64a79c02a97f73f64d4e7fc4cc1984e64affab14075e7c1f9a2db34/regex-2026.2.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd477d5f79920338107f04aa645f094032d9e3030cc55be581df3d1ef61aa318", size = 773920, upload-time = "2026-02-28T02:16:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/72/f8/3f9c2c2af37aedb3f5a1e7227f81bea065028785260d9cacc488e43e6997/regex-2026.2.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b49eb78048c6354f49e91e4b77da21257fecb92256b6d599ae44403cab30b05b", size = 846681, upload-time = "2026-02-28T02:16:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/8db04a334571359f4d127d8f89550917ec6561a2fddfd69cd91402b47482/regex-2026.2.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a25c7701e4f7a70021db9aaf4a4a0a67033c6318752146e03d1b94d32006217e", size = 755565, upload-time = "2026-02-28T02:16:11.972Z" }, + { url = "https://files.pythonhosted.org/packages/da/bc/91c22f384d79324121b134c267a86ca90d11f8016aafb1dc5bee05890ee3/regex-2026.2.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9dd450db6458387167e033cfa80887a34c99c81d26da1bf8b0b41bf8c9cac88e", size = 835789, upload-time = "2026-02-28T02:16:14.036Z" }, + { url = "https://files.pythonhosted.org/packages/46/a7/4cc94fd3af01dcfdf5a9ed75c8e15fd80fcd62cc46da7592b1749e9c35db/regex-2026.2.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2954379dd20752e82d22accf3ff465311cbb2bac6c1f92c4afd400e1757f7451", size = 780094, upload-time = "2026-02-28T02:16:15.468Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/e5a38f420af3c77cab4a65f0c3a55ec02ac9babf04479cfd282d356988a6/regex-2026.2.28-cp310-cp310-win32.whl", hash = "sha256:1f8b17be5c27a684ea6759983c13506bd77bfc7c0347dff41b18ce5ddd2ee09a", size = 266025, upload-time = "2026-02-28T02:16:16.828Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/205c4c1466a36e04d90afcd01d8908bac327673050c7fe316b2416d99d3d/regex-2026.2.28-cp310-cp310-win_amd64.whl", hash = "sha256:dd8847c4978bc3c7e6c826fb745f5570e518b8459ac2892151ce6627c7bc00d5", size = 277965, upload-time = "2026-02-28T02:16:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4d/29b58172f954b6ec2c5ed28529a65e9026ab96b4b7016bcd3858f1c31d3c/regex-2026.2.28-cp310-cp310-win_arm64.whl", hash = "sha256:73cdcdbba8028167ea81490c7f45280113e41db2c7afb65a276f4711fa3bcbff", size = 270336, upload-time = "2026-02-28T02:16:20.735Z" }, + { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/02/291c0ae3f3a10cea941d0f5366da1843d8d1fa8a25b0671e20a0e454bb38/regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098", size = 791924, upload-time = "2026-02-28T02:16:26.863Z" }, + { url = "https://files.pythonhosted.org/packages/0f/57/f0235cc520d9672742196c5c15098f8f703f2758d48d5a7465a56333e496/regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2", size = 860095, upload-time = "2026-02-28T02:16:28.772Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/393c94cbedda79a0f5f2435ebd01644aba0b338d327eb24b4aa5b8d6c07f/regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64", size = 906583, upload-time = "2026-02-28T02:16:30.977Z" }, + { url = "https://files.pythonhosted.org/packages/2c/73/a72820f47ca5abf2b5d911d0407ba5178fc52cf9780191ed3a54f5f419a2/regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022", size = 800234, upload-time = "2026-02-28T02:16:32.55Z" }, + { url = "https://files.pythonhosted.org/packages/34/b3/6e6a4b7b31fa998c4cf159a12cbeaf356386fbd1a8be743b1e80a3da51e4/regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1", size = 772803, upload-time = "2026-02-28T02:16:34.029Z" }, + { url = "https://files.pythonhosted.org/packages/10/e7/5da0280c765d5a92af5e1cd324b3fe8464303189cbaa449de9a71910e273/regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a", size = 781117, upload-time = "2026-02-28T02:16:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/76/39/0b8d7efb256ae34e1b8157acc1afd8758048a1cf0196e1aec2e71fd99f4b/regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27", size = 854224, upload-time = "2026-02-28T02:16:38.119Z" }, + { url = "https://files.pythonhosted.org/packages/21/ff/a96d483ebe8fe6d1c67907729202313895d8de8495569ec319c6f29d0438/regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae", size = 761898, upload-time = "2026-02-28T02:16:40.333Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/d4f2e75cb4a54b484e796017e37c0d09d8a0a837de43d17e238adf163f4e/regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea", size = 844832, upload-time = "2026-02-28T02:16:41.875Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/428a135cf5e15e4e11d1e696eb2bf968362f8ea8a5f237122e96bc2ae950/regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b", size = 788347, upload-time = "2026-02-28T02:16:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/68691428851cf9c9c3707217ab1d9b47cfeec9d153a49919e6c368b9e926/regex-2026.2.28-cp311-cp311-win32.whl", hash = "sha256:948c12ef30ecedb128903c2c2678b339746eb7c689c5c21957c4a23950c96d15", size = 266033, upload-time = "2026-02-28T02:16:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/1483de1c57024e89296cbcceb9cccb3f625d416ddb46e570be185c9b05a9/regex-2026.2.28-cp311-cp311-win_amd64.whl", hash = "sha256:fd63453f10d29097cc3dc62d070746523973fb5aa1c66d25f8558bebd47fed61", size = 277978, upload-time = "2026-02-28T02:16:46.75Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/abec45dc6e7252e3dbc797120496e43bb5730a7abf0d9cb69340696a2f2d/regex-2026.2.28-cp311-cp311-win_arm64.whl", hash = "sha256:00f2b8d9615aa165fdff0a13f1a92049bfad555ee91e20d246a51aa0b556c60a", size = 270340, upload-time = "2026-02-28T02:16:48.626Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, + { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, + { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, + { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, + { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, + { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" }, + { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" }, + { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, + { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, + { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, + { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -2715,6 +3401,278 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] +[[package]] +name = "schedule" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, + { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, + { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, + { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" }, + { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" }, + { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -2751,6 +3709,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "sphinx" version = "8.1.3" @@ -2949,6 +3916,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, ] +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + [[package]] name = "starlette" version = "0.52.1" @@ -2980,8 +3961,7 @@ version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "pandas" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/9a/37d92a6b470dc9088612c2399a68f1a9ac22872d4e1eff416818e22ab11b/ta-0.11.0.tar.gz", hash = "sha256:de86af43418420bd6b088a2ea9b95483071bf453c522a8441bc2f12bcf8493fd", size = 25308, upload-time = "2023-11-02T13:53:35.434Z" } @@ -3042,6 +4022,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/4c/d341020377f8b183405bdf3c5717fc2ca04a8d33b5c59b2348377ee459d9/ta_lib-0.6.8-cp314-cp314-win_arm64.whl", hash = "sha256:bfad1202fb1f9140e3810cc607058395f59032d9128cc0d716900c78bea5f337", size = 755896, upload-time = "2025-10-20T20:49:39.9Z" }, ] +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -3148,18 +4146,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/bf/c8d12a2c86dbfd7f40fb2f56fbf5a505ccf2d9ce131eb559dfc7c51e1a04/torch-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b2a43985ff5ef6ddd923bbcf99943e5f58059805787c5c9a2622bf05ca2965b0", size = 114792991, upload-time = "2026-03-23T18:08:19.216Z" }, ] +[[package]] +name = "tornado" +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + [[package]] name = "triton" version = "3.6.0" @@ -3211,6 +4235,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -3234,6 +4270,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] +[[package]] +name = "vectorbt" +version = "0.28.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anywidget" }, + { name = "dateparser" }, + { name = "dill" }, + { name = "imageio" }, + { name = "ipywidgets" }, + { name = "matplotlib" }, + { name = "mypy-extensions" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "plotly" }, + { name = "pytz" }, + { name = "requests" }, + { name = "schedule" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/92/d8f895bf16daac55311b8e218ccd84f6cf3a3e0a10d8b69282494fdff55f/vectorbt-0.28.5.tar.gz", hash = "sha256:79009c1048b80b4744d29abea3198fe8ec06acea66a42ae583745e8ac4c85fe1", size = 498084, upload-time = "2026-03-26T22:18:01.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/61/558641336106d99b8437415ddde6dd49c3c2342fccb9df481bedff989266/vectorbt-0.28.5-py3-none-any.whl", hash = "sha256:8820f2d13472947d0207f931ec1b0e041fc797aaf3db8eccd43cacd00663b894", size = 421680, upload-time = "2026-03-26T22:18:00.07Z" }, +] + [[package]] name = "virtualenv" version = "21.2.0" @@ -3250,6 +4316,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ] +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "xyzservices" +version = "2025.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29e742a509913badb53ce0b38f64b6db859e2f6339da9/xyzservices-2025.11.0.tar.gz", hash = "sha256:2fc72b49502b25023fd71e8f532fb4beddbbf0aa124d90ea25dba44f545e17ce", size = 1135703, upload-time = "2025-11-22T11:31:51.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" }, +] + +[[package]] +name = "yfinance" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "frozendict" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/1b/431d0ebd6a1e9deaffc8627cc4d26fd869841f31a1429cab7443eced0766/yfinance-1.2.0.tar.gz", hash = "sha256:80cec643eb983330ca63debab1b5492334fa1e6338d82cb17dd4e7b95079cfab", size = 140501, upload-time = "2026-02-16T19:52:34.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/60/462859de757ac56830824da7e8cf314b8b0321af5853df867c84cd6c2128/yfinance-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c27d1ebfc6275f476721cc6dba035a49d0cf9a806d6aa1785c9e10cf8a610d8", size = 130247, upload-time = "2026-02-16T19:52:33.109Z" }, +] + [[package]] name = "zipp" version = "3.23.0" diff --git a/wasm/Cargo.lock b/wasm/Cargo.lock index a430c15..07d562e 100644 --- a/wasm/Cargo.lock +++ b/wasm/Cargo.lock @@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "ferro_ta_core" -version = "1.0.6" +version = "1.2.0" [[package]] name = "ferro_ta_wasm" -version = "1.0.6" +version = "1.2.0" dependencies = [ "ferro_ta_core", "js-sys", diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 5cafb38..128921e 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferro_ta_wasm" -version = "1.0.6" +version = "1.2.0" edition = "2021" description = "WebAssembly bindings for ferro-ta technical analysis indicators" license = "MIT" diff --git a/wasm/package.json b/wasm/package.json index 5f20a6f..6e621ea 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -1,6 +1,6 @@ { "name": "ferro-ta-wasm", - "version": "1.0.6", + "version": "1.2.0", "description": "WebAssembly bindings for ferro-ta technical analysis indicators", "main": "pkg/ferro_ta_wasm.js", "types": "pkg/ferro_ta_wasm.d.ts", diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 4d93e14..1f2323f 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -68,23 +68,7 @@ fn from_vec(v: Vec) -> Float64Array { #[wasm_bindgen] pub fn sma(close: &Float64Array, timeperiod: usize) -> Float64Array { let prices = to_vec(close); - let n = prices.len(); - let mut result = vec![f64::NAN; n]; - - if timeperiod == 0 || n < timeperiod { - return from_vec(result); - } - - // Seed: sum of first window - let mut window_sum: f64 = prices[..timeperiod].iter().sum(); - result[timeperiod - 1] = window_sum / timeperiod as f64; - - for i in timeperiod..n { - window_sum += prices[i] - prices[i - timeperiod]; - result[i] = window_sum / timeperiod as f64; - } - - from_vec(result) + from_vec(ferro_ta_core::overlap::sma(&prices, timeperiod)) } // --------------------------------------------------------------------------- @@ -102,27 +86,7 @@ pub fn sma(close: &Float64Array, timeperiod: usize) -> Float64Array { #[wasm_bindgen] pub fn ema(close: &Float64Array, timeperiod: usize) -> Float64Array { let prices = to_vec(close); - let n = prices.len(); - let mut result = vec![f64::NAN; n]; - - if timeperiod == 0 || n < timeperiod { - return from_vec(result); - } - - let k = 2.0 / (timeperiod as f64 + 1.0); - - // Seed with SMA of first window - let seed: f64 = prices[..timeperiod].iter().sum::() / timeperiod as f64; - result[timeperiod - 1] = seed; - let mut prev = seed; - - for i in timeperiod..n { - let val = prices[i] * k + prev * (1.0 - k); - result[i] = val; - prev = val; - } - - from_vec(result) + from_vec(ferro_ta_core::overlap::ema(&prices, timeperiod)) } // --------------------------------------------------------------------------- @@ -148,29 +112,7 @@ pub fn bbands( nbdevdn: f64, ) -> Array { let prices = to_vec(close); - let n = prices.len(); - let mut upper = vec![f64::NAN; n]; - let mut middle = vec![f64::NAN; n]; - let mut lower = vec![f64::NAN; n]; - - if timeperiod == 0 || n < timeperiod { - let out = Array::new(); - out.push(&from_vec(upper)); - out.push(&from_vec(middle)); - out.push(&from_vec(lower)); - return out; - } - - for i in (timeperiod - 1)..n { - let window = &prices[(i + 1 - timeperiod)..=i]; - let mean = window.iter().sum::() / timeperiod as f64; - let variance = window.iter().map(|&x| (x - mean).powi(2)).sum::() / timeperiod as f64; - let stddev = variance.sqrt(); - middle[i] = mean; - upper[i] = mean + nbdevup * stddev; - lower[i] = mean - nbdevdn * stddev; - } - + let (upper, middle, lower) = ferro_ta_core::overlap::bbands(&prices, timeperiod, nbdevup, nbdevdn); let out = Array::new(); out.push(&from_vec(upper)); out.push(&from_vec(middle)); @@ -193,44 +135,7 @@ pub fn bbands( #[wasm_bindgen] pub fn rsi(close: &Float64Array, timeperiod: usize) -> Float64Array { let prices = to_vec(close); - let n = prices.len(); - let mut result = vec![f64::NAN; n]; - - if timeperiod == 0 || n <= timeperiod { - return from_vec(result); - } - - // Compute gains and losses - let diffs: Vec = prices.windows(2).map(|w| w[1] - w[0]).collect(); - - // Seed average gain / loss over first `timeperiod` bars - let mut avg_gain: f64 = diffs[..timeperiod] - .iter() - .map(|&d| if d > 0.0 { d } else { 0.0 }) - .sum::() - / timeperiod as f64; - let mut avg_loss: f64 = diffs[..timeperiod] - .iter() - .map(|&d| if d < 0.0 { -d } else { 0.0 }) - .sum::() - / timeperiod as f64; - - // First RSI value at index `timeperiod` - let rs = if avg_loss == 0.0 { f64::INFINITY } else { avg_gain / avg_loss }; - result[timeperiod] = 100.0 - 100.0 / (1.0 + rs); - - // Wilder smoothing for remaining values - for i in (timeperiod + 1)..n { - let diff = diffs[i - 1]; - let gain = if diff > 0.0 { diff } else { 0.0 }; - let loss = if diff < 0.0 { -diff } else { 0.0 }; - avg_gain = (avg_gain * (timeperiod as f64 - 1.0) + gain) / timeperiod as f64; - avg_loss = (avg_loss * (timeperiod as f64 - 1.0) + loss) / timeperiod as f64; - let rs = if avg_loss == 0.0 { f64::INFINITY } else { avg_gain / avg_loss }; - result[i] = 100.0 - 100.0 / (1.0 + rs); - } - - from_vec(result) + from_vec(ferro_ta_core::momentum::rsi(&prices, timeperiod)) } // --------------------------------------------------------------------------- @@ -257,38 +162,7 @@ pub fn atr( let h = to_vec(high); let l = to_vec(low); let c = to_vec(close); - let n = h.len(); - let mut result = vec![f64::NAN; n]; - - if timeperiod == 0 || n <= timeperiod { - return from_vec(result); - } - if l.len() != n || c.len() != n { - return from_vec(result); - } - - // True Range for each bar - let mut tr = vec![0.0f64; n]; - tr[0] = h[0] - l[0]; // first bar: no previous close - for i in 1..n { - let hl = h[i] - l[i]; - let hpc = (h[i] - c[i - 1]).abs(); - let lpc = (l[i] - c[i - 1]).abs(); - tr[i] = hl.max(hpc).max(lpc); - } - - // Seed: SMA of first `timeperiod` true ranges - let seed: f64 = tr[1..=timeperiod].iter().sum::() / timeperiod as f64; - result[timeperiod] = seed; - let mut prev = seed; - - for i in (timeperiod + 1)..n { - let val = (prev * (timeperiod as f64 - 1.0) + tr[i]) / timeperiod as f64; - result[i] = val; - prev = val; - } - - from_vec(result) + from_vec(ferro_ta_core::volatility::atr(&h, &l, &c, timeperiod)) } // --------------------------------------------------------------------------- @@ -307,25 +181,7 @@ pub fn atr( pub fn obv(close: &Float64Array, volume: &Float64Array) -> Float64Array { let c = to_vec(close); let v = to_vec(volume); - let n = c.len(); - let mut result = vec![0.0f64; n]; - - if n == 0 || v.len() != n { - return from_vec(result); - } - - result[0] = v[0]; - for i in 1..n { - if c[i] > c[i - 1] { - result[i] = result[i - 1] + v[i]; - } else if c[i] < c[i - 1] { - result[i] = result[i - 1] - v[i]; - } else { - result[i] = result[i - 1]; - } - } - - from_vec(result) + from_vec(ferro_ta_core::volume::obv(&c, &v)) } // --------------------------------------------------------------------------- @@ -361,18 +217,7 @@ pub fn wma(close: &Float64Array, timeperiod: usize) -> Float64Array { #[wasm_bindgen] pub fn mom(close: &Float64Array, timeperiod: usize) -> Float64Array { let prices = to_vec(close); - let n = prices.len(); - let mut result = vec![f64::NAN; n]; - - if timeperiod == 0 || n <= timeperiod { - return from_vec(result); - } - - for i in timeperiod..n { - result[i] = prices[i] - prices[i - timeperiod]; - } - - from_vec(result) + from_vec(ferro_ta_core::momentum::mom(&prices, timeperiod)) } // --------------------------------------------------------------------------- @@ -401,53 +246,8 @@ pub fn stochf( let h = to_vec(high); let l = to_vec(low); let c = to_vec(close); - let n = c.len(); - - let nan_out = || { - let out = Array::new(); - out.push(&from_vec(vec![f64::NAN; n])); - out.push(&from_vec(vec![f64::NAN; n])); - out - }; - - if fastk_period == 0 || fastd_period == 0 || n < fastk_period { - return nan_out(); - } - if h.len() != n || l.len() != n { - return nan_out(); - } - - // Fast %K: (close - lowest_low) / (highest_high - lowest_low) * 100 - let mut fastk = vec![f64::NAN; n]; - for i in (fastk_period - 1)..n { - let low_min = l[(i + 1 - fastk_period)..=i] - .iter() - .cloned() - .fold(f64::INFINITY, f64::min); - let high_max = h[(i + 1 - fastk_period)..=i] - .iter() - .cloned() - .fold(f64::NEG_INFINITY, f64::max); - let range = high_max - low_min; - fastk[i] = if range > 0.0 { - (c[i] - low_min) / range * 100.0 - } else { - 50.0 // all bars at same price — neutral - }; - } - - // Fast %D: SMA(fastd_period) of fast %K - let mut fastd = vec![f64::NAN; n]; - let k_start = fastk_period - 1; - if n >= k_start + fastd_period { - for i in (k_start + fastd_period - 1)..n { - let window = &fastk[(i + 1 - fastd_period)..=i]; - if window.iter().all(|x| x.is_finite()) { - fastd[i] = window.iter().sum::() / fastd_period as f64; - } - } - } - + // stoch with slowk_period=1 yields fastk as slowk, fastd as slowd + let (fastk, fastd) = ferro_ta_core::momentum::stoch(&h, &l, &c, fastk_period, 1, fastd_period); let out = Array::new(); out.push(&from_vec(fastk)); out.push(&from_vec(fastd)); @@ -541,82 +341,8 @@ pub fn macd( signalperiod: usize, ) -> Array { let prices = to_vec(close); - let n = prices.len(); - let nan_result = || { - let out = Array::new(); - out.push(&from_vec(vec![f64::NAN; n])); - out.push(&from_vec(vec![f64::NAN; n])); - out.push(&from_vec(vec![f64::NAN; n])); - out - }; - - if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod { - return nan_result(); - } - if n < slowperiod { - return nan_result(); - } - - // Helper: SMA-seeded EMA - let ema_vec = |data: &[f64], period: usize| -> Vec { - let len = data.len(); - let mut result = vec![f64::NAN; len]; - if period == 0 || len < period { - return result; - } - let k = 2.0 / (period as f64 + 1.0); - let seed: f64 = data[..period].iter().sum::() / period as f64; - result[period - 1] = seed; - for i in period..len { - result[i] = data[i] * k + result[i - 1] * (1.0 - k); - } - result - }; - - let fast_ema = ema_vec(&prices, fastperiod); - let slow_ema = ema_vec(&prices, slowperiod); - - // MACD line = fast EMA − slow EMA (valid from index slowperiod - 1) - let mut macd_line = vec![f64::NAN; n]; - for i in (slowperiod - 1)..n { - if fast_ema[i].is_finite() && slow_ema[i].is_finite() { - macd_line[i] = fast_ema[i] - slow_ema[i]; - } - } - - // Signal line = EMA(signalperiod) of macd_line, seeded at index slowperiod - 1 - let macd_start = slowperiod - 1; - let mut signal_line = vec![f64::NAN; n]; - let signal_seed_end = macd_start + signalperiod; - if signal_seed_end > n { - let out = Array::new(); - out.push(&from_vec(macd_line.clone())); - out.push(&from_vec(signal_line)); - out.push(&from_vec(vec![f64::NAN; n])); - return out; - } - - // Seed: SMA of first signalperiod MACD values - let seed: f64 = macd_line[macd_start..signal_seed_end] - .iter() - .sum::() - / signalperiod as f64; - signal_line[signal_seed_end - 1] = seed; - let k = 2.0 / (signalperiod as f64 + 1.0); - for i in signal_seed_end..n { - if macd_line[i].is_finite() { - signal_line[i] = macd_line[i] * k + signal_line[i - 1] * (1.0 - k); - } - } - - // Histogram = MACD − signal - let mut histogram = vec![f64::NAN; n]; - for i in (signal_seed_end - 1)..n { - if macd_line[i].is_finite() && signal_line[i].is_finite() { - histogram[i] = macd_line[i] - signal_line[i]; - } - } - + let (macd_line, signal_line, histogram) = + ferro_ta_core::overlap::macd(&prices, fastperiod, slowperiod, signalperiod); let out = Array::new(); out.push(&from_vec(macd_line)); out.push(&from_vec(signal_line)); @@ -624,6 +350,913 @@ pub fn macd( out } +// --------------------------------------------------------------------------- +// CommissionModel — advanced commission and tax model for Indian and global markets +// --------------------------------------------------------------------------- + +/// Advanced commission and tax model (WASM binding). +/// +/// 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). +/// +/// Use the static factory methods for built-in presets, or construct and +/// set fields individually. +#[wasm_bindgen] +pub struct CommissionModel { + inner: ferro_ta_core::commission::CommissionModel, +} + +#[wasm_bindgen] +impl CommissionModel { + /// Create a zero-commission model. + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { inner: ferro_ta_core::commission::CommissionModel::default() } + } + + // ---- Field getters/setters ------------------------------------------ + + #[wasm_bindgen(getter)] pub fn flat_per_order(&self) -> f64 { self.inner.flat_per_order } + #[wasm_bindgen(setter)] pub fn set_flat_per_order(&mut self, v: f64) { self.inner.flat_per_order = v; } + + #[wasm_bindgen(getter)] pub fn rate_of_value(&self) -> f64 { self.inner.rate_of_value } + #[wasm_bindgen(setter)] pub fn set_rate_of_value(&mut self, v: f64) { self.inner.rate_of_value = v; } + + #[wasm_bindgen(getter)] pub fn per_lot(&self) -> f64 { self.inner.per_lot } + #[wasm_bindgen(setter)] pub fn set_per_lot(&mut self, v: f64) { self.inner.per_lot = v; } + + #[wasm_bindgen(getter)] pub fn max_brokerage(&self) -> f64 { self.inner.max_brokerage } + #[wasm_bindgen(setter)] pub fn set_max_brokerage(&mut self, v: f64) { self.inner.max_brokerage = v; } + + #[wasm_bindgen(getter)] pub fn stt_rate(&self) -> f64 { self.inner.stt_rate } + #[wasm_bindgen(setter)] pub fn set_stt_rate(&mut self, v: f64) { self.inner.stt_rate = v; } + + #[wasm_bindgen(getter)] pub fn stt_on_buy(&self) -> bool { self.inner.stt_on_buy } + #[wasm_bindgen(setter)] pub fn set_stt_on_buy(&mut self, v: bool) { self.inner.stt_on_buy = v; } + + #[wasm_bindgen(getter)] pub fn stt_on_sell(&self) -> bool { self.inner.stt_on_sell } + #[wasm_bindgen(setter)] pub fn set_stt_on_sell(&mut self, v: bool) { self.inner.stt_on_sell = v; } + + #[wasm_bindgen(getter)] pub fn exchange_charges_rate(&self) -> f64 { self.inner.exchange_charges_rate } + #[wasm_bindgen(setter)] pub fn set_exchange_charges_rate(&mut self, v: f64) { self.inner.exchange_charges_rate = v; } + + #[wasm_bindgen(getter)] pub fn regulatory_charges_rate(&self) -> f64 { self.inner.regulatory_charges_rate } + #[wasm_bindgen(setter)] pub fn set_regulatory_charges_rate(&mut self, v: f64) { self.inner.regulatory_charges_rate = v; } + + #[wasm_bindgen(getter)] pub fn gst_rate(&self) -> f64 { self.inner.gst_rate } + #[wasm_bindgen(setter)] pub fn set_gst_rate(&mut self, v: f64) { self.inner.gst_rate = v; } + + #[wasm_bindgen(getter)] pub fn stamp_duty_rate(&self) -> f64 { self.inner.stamp_duty_rate } + #[wasm_bindgen(setter)] pub fn set_stamp_duty_rate(&mut self, v: f64) { self.inner.stamp_duty_rate = v; } + + #[wasm_bindgen(getter)] pub fn lot_size(&self) -> f64 { self.inner.lot_size } + #[wasm_bindgen(setter)] pub fn set_lot_size(&mut self, v: f64) { self.inner.lot_size = v; } + + // ---- Compute -------------------------------------------------------- + + /// Total transaction cost in absolute currency units. + 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). + 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. + pub fn zero() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::zero() } + } + + /// Indian equity delivery preset. + pub fn equity_delivery_india() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::equity_delivery_india() } + } + + /// Indian equity intraday preset. + pub fn equity_intraday_india() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::equity_intraday_india() } + } + + /// Indian index futures preset. + pub fn futures_india() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::futures_india() } + } + + /// Indian index options preset. + pub fn options_india() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::options_india() } + } + + /// Simple proportional model (no taxes, `rate` fraction both ways). + pub fn proportional(rate: f64) -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::proportional(rate) } + } + + // ---- JSON (minimal manual serialization — no serde in WASM) ---------- + + /// Serialize key fields to a JSON string (no serde dependency). + pub fn to_json_string(&self) -> String { + let m = &self.inner; + format!( + r#"{{"flat_per_order":{},"rate_of_value":{},"per_lot":{},"max_brokerage":{},"stt_rate":{},"stt_on_buy":{},"stt_on_sell":{},"exchange_charges_rate":{},"regulatory_charges_rate":{},"gst_rate":{},"stamp_duty_rate":{},"lot_size":{},"spread_bps":{},"short_borrow_rate_annual":{}}}"#, + m.flat_per_order, m.rate_of_value, m.per_lot, m.max_brokerage, + m.stt_rate, m.stt_on_buy, m.stt_on_sell, + m.exchange_charges_rate, m.regulatory_charges_rate, + m.gst_rate, m.stamp_duty_rate, m.lot_size, + m.spread_bps, m.short_borrow_rate_annual, + ) + } +} + +// =========================================================================== +// Price Transform +// =========================================================================== + +/// Average Price: (open + high + low + close) / 4. +#[wasm_bindgen] +pub fn avgprice( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, +) -> Float64Array { + let o = to_vec(open); + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::price_transform::avgprice(&o, &h, &l, &c)) +} + +/// Median Price: (high + low) / 2. +#[wasm_bindgen] +pub fn medprice(high: &Float64Array, low: &Float64Array) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + from_vec(ferro_ta_core::price_transform::medprice(&h, &l)) +} + +/// Typical Price: (high + low + close) / 3. +#[wasm_bindgen] +pub fn typprice( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::price_transform::typprice(&h, &l, &c)) +} + +/// Weighted Close Price: (high + low + close * 2) / 4. +#[wasm_bindgen] +pub fn wclprice( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::price_transform::wclprice(&h, &l, &c)) +} + +// =========================================================================== +// Alerts +// =========================================================================== + +/// Fire an alert when series crosses a threshold level. +/// direction: 1 = cross above, -1 = cross below. +/// Returns Int8Array: 1 at crossing bars, 0 elsewhere. +#[wasm_bindgen] +pub fn check_threshold(series: &Float64Array, level: f64, direction: i32) -> js_sys::Int8Array { + let s = to_vec(series); + let result = ferro_ta_core::alerts::check_threshold(&s, level, direction); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Detect cross-over/cross-under events between fast and slow series. +/// Returns Int8Array: 1 = bullish, -1 = bearish, 0 = none. +#[wasm_bindgen] +pub fn check_cross(fast: &Float64Array, slow: &Float64Array) -> js_sys::Int8Array { + let f = to_vec(fast); + let s = to_vec(slow); + let result = ferro_ta_core::alerts::check_cross(&f, &s); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Collect bar indices where mask is non-zero. +#[wasm_bindgen] +pub fn collect_alert_bars(mask: &js_sys::Int8Array) -> Float64Array { + let n = mask.length() as usize; + let mut m = vec![0i8; n]; + mask.copy_to(&mut m); + let result = ferro_ta_core::alerts::collect_alert_bars(&m); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Signals +// =========================================================================== + +/// Compute fractional rank of each element (1-based, ascending). +#[wasm_bindgen] +pub fn rank_series(x: &Float64Array) -> Float64Array { + let xv = to_vec(x); + from_vec(ferro_ta_core::signals::rank_values(&xv)) +} + +/// Return indices of the N largest values. +#[wasm_bindgen] +pub fn top_n_indices(x: &Float64Array, n: usize) -> Float64Array { + let xv = to_vec(x); + let result = ferro_ta_core::signals::top_n_indices(&xv, n); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +/// Return indices of the N smallest values. +#[wasm_bindgen] +pub fn bottom_n_indices(x: &Float64Array, n: usize) -> Float64Array { + let xv = to_vec(x); + let result = ferro_ta_core::signals::bottom_n_indices(&xv, n); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Crypto +// =========================================================================== + +/// Cumulative PnL from funding rate payments. +#[wasm_bindgen] +pub fn funding_cumulative_pnl( + position_size: &Float64Array, + funding_rate: &Float64Array, +) -> Float64Array { + let pos = to_vec(position_size); + let rate = to_vec(funding_rate); + from_vec(ferro_ta_core::crypto::funding_cumulative_pnl(&pos, &rate)) +} + +/// Assign sequential integer labels based on fixed period size. +#[wasm_bindgen] +pub fn continuous_bar_labels(n_bars: usize, period_bars: usize) -> Float64Array { + let result = ferro_ta_core::crypto::continuous_bar_labels(n_bars, period_bars); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Math Ops +// =========================================================================== + +/// Rolling sum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_sum(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + from_vec(ferro_ta_core::math_ops::rolling_sum(&prices, timeperiod)) +} + +/// Rolling maximum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_max(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + from_vec(ferro_ta_core::math_ops::rolling_max(&prices, timeperiod)) +} + +/// Rolling minimum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_min(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + from_vec(ferro_ta_core::math_ops::rolling_min(&prices, timeperiod)) +} + +/// Index of rolling maximum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_maxindex(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + let result = ferro_ta_core::math_ops::rolling_maxindex(&prices, timeperiod); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +/// Index of rolling minimum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_minindex(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + let result = ferro_ta_core::math_ops::rolling_minindex(&prices, timeperiod); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Regime +// =========================================================================== + +/// Label bars as trend (1), range (0), or NaN (-1) based on ADX threshold. +#[wasm_bindgen] +pub fn regime_adx(adx: &Float64Array, threshold: f64) -> js_sys::Int8Array { + let a = to_vec(adx); + let result = ferro_ta_core::regime::regime_adx(&a, threshold); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Label bars using ADX + ATR-ratio combined rule. +#[wasm_bindgen] +pub fn regime_combined( + adx: &Float64Array, + atr: &Float64Array, + close: &Float64Array, + adx_threshold: f64, + atr_pct_threshold: f64, +) -> js_sys::Int8Array { + let a = to_vec(adx); + let r = to_vec(atr); + let c = to_vec(close); + let result = ferro_ta_core::regime::regime_combined(&a, &r, &c, adx_threshold, atr_pct_threshold); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Detect structural breaks using CUSUM approach. +#[wasm_bindgen] +pub fn detect_breaks_cusum( + series: &Float64Array, + window: usize, + threshold: f64, + slack: f64, +) -> js_sys::Int8Array { + let s = to_vec(series); + let result = ferro_ta_core::regime::detect_breaks_cusum(&s, window, threshold, slack); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Detect volatility regime breaks using rolling variance ratio. +#[wasm_bindgen] +pub fn rolling_variance_break( + series: &Float64Array, + short_window: usize, + long_window: usize, + threshold: f64, +) -> js_sys::Int8Array { + let s = to_vec(series); + let result = ferro_ta_core::regime::rolling_variance_break(&s, short_window, long_window, threshold); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +// =========================================================================== +// Chunked +// =========================================================================== + +/// Remove first overlap elements from an array. +#[wasm_bindgen] +pub fn trim_overlap(chunk_out: &Float64Array, overlap: usize) -> Float64Array { + let s = to_vec(chunk_out); + from_vec(ferro_ta_core::chunked::trim_overlap(&s, overlap)) +} + +/// Compute (start, end) index pairs for chunked processing. +/// Returns flat Float64Array: [start0, end0, start1, end1, ...]. +#[wasm_bindgen] +pub fn make_chunk_ranges(n: usize, chunk_size: usize, overlap: usize) -> Float64Array { + let result = ferro_ta_core::chunked::make_chunk_ranges(n, chunk_size, overlap); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +/// Forward-fill NaN values in a 1-D array. +#[wasm_bindgen] +pub fn forward_fill_nan(values: &Float64Array) -> Float64Array { + let input = to_vec(values); + from_vec(ferro_ta_core::chunked::forward_fill_nan(&input)) +} + +// =========================================================================== +// Extended Indicators (Sprint 2) +// =========================================================================== + +/// Volume Weighted Average Price (cumulative or rolling). +#[wasm_bindgen] +pub fn vwap( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + volume: &Float64Array, + timeperiod: usize, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let v = to_vec(volume); + from_vec(ferro_ta_core::extended::vwap(&h, &l, &c, &v, timeperiod)) +} + +/// Volume Weighted Moving Average. +#[wasm_bindgen] +pub fn vwma(close: &Float64Array, volume: &Float64Array, timeperiod: usize) -> Float64Array { + let c = to_vec(close); + let v = to_vec(volume); + from_vec(ferro_ta_core::extended::vwma(&c, &v, timeperiod)) +} + +/// ATR-based Supertrend indicator. +/// Returns `[supertrend_line, direction_as_f64]`. +#[wasm_bindgen] +pub fn supertrend( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, + multiplier: f64, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (line, direction) = ferro_ta_core::extended::supertrend(&h, &l, &c, timeperiod, multiplier); + let dir_f64: Vec = direction.iter().map(|&d| d as f64).collect(); + let out = Array::new(); + out.push(&from_vec(line)); + out.push(&from_vec(dir_f64)); + out +} + +/// Donchian Channels — rolling highest high / lowest low. +/// Returns `[upper, middle, lower]`. +#[wasm_bindgen] +pub fn donchian(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let (upper, middle, lower) = ferro_ta_core::extended::donchian(&h, &l, timeperiod); + let out = Array::new(); + out.push(&from_vec(upper)); + out.push(&from_vec(middle)); + out.push(&from_vec(lower)); + out +} + +/// Choppiness Index — measures market choppiness vs trending. +#[wasm_bindgen] +pub fn choppiness_index( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::extended::choppiness_index(&h, &l, &c, timeperiod)) +} + +/// Keltner Channels — EMA +/- (multiplier x ATR). +/// Returns `[upper, middle, lower]`. +#[wasm_bindgen] +pub fn keltner_channels( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, + atr_period: usize, + multiplier: f64, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (upper, middle, lower) = + ferro_ta_core::extended::keltner_channels(&h, &l, &c, timeperiod, atr_period, multiplier); + let out = Array::new(); + out.push(&from_vec(upper)); + out.push(&from_vec(middle)); + out.push(&from_vec(lower)); + out +} + +/// Hull Moving Average (HMA). +#[wasm_bindgen] +pub fn hull_ma(close: &Float64Array, timeperiod: usize) -> Float64Array { + let c = to_vec(close); + from_vec(ferro_ta_core::extended::hull_ma(&c, timeperiod)) +} + +/// Chandelier Exit — ATR-based trailing stop levels. +/// Returns `[long_exit, short_exit]`. +#[wasm_bindgen] +pub fn chandelier_exit( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, + multiplier: f64, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (long_exit, short_exit) = + ferro_ta_core::extended::chandelier_exit(&h, &l, &c, timeperiod, multiplier); + let out = Array::new(); + out.push(&from_vec(long_exit)); + out.push(&from_vec(short_exit)); + out +} + +/// Ichimoku Cloud (Ichimoku Kinko Hyo). +/// Returns `[tenkan, kijun, senkou_a, senkou_b, chikou]`. +#[wasm_bindgen] +pub fn ichimoku( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + tenkan: usize, + kijun: usize, + senkou_b: usize, + displacement: usize, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (tenkan_out, kijun_out, senkou_a_out, senkou_b_out, chikou_out) = + ferro_ta_core::extended::ichimoku(&h, &l, &c, tenkan, kijun, senkou_b, displacement); + let out = Array::new(); + out.push(&from_vec(tenkan_out)); + out.push(&from_vec(kijun_out)); + out.push(&from_vec(senkou_a_out)); + out.push(&from_vec(senkou_b_out)); + out.push(&from_vec(chikou_out)); + out +} + +/// Pivot Points — support / resistance levels. +/// Returns `[pivot, r1, s1, r2, s2]`. +#[wasm_bindgen(js_name = "pivot_points")] +pub fn pivot_points( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + method: &str, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (pivot, r1, s1, r2, s2) = ferro_ta_core::extended::pivot_points(&h, &l, &c, method); + let out = Array::new(); + out.push(&from_vec(pivot)); + out.push(&from_vec(r1)); + out.push(&from_vec(s1)); + out.push(&from_vec(r2)); + out.push(&from_vec(s2)); + out +} + +// =========================================================================== +// Portfolio Analytics (Sprint 2) +// =========================================================================== + +/// Full-sample OLS beta of asset vs benchmark returns. +#[wasm_bindgen] +pub fn beta_full(asset_returns: &Float64Array, benchmark_returns: &Float64Array) -> f64 { + let a = to_vec(asset_returns); + let b = to_vec(benchmark_returns); + ferro_ta_core::portfolio::beta_full(&a, &b) +} + +/// Rolling beta of asset vs benchmark over a sliding window. +#[wasm_bindgen] +pub fn rolling_beta( + asset: &Float64Array, + benchmark: &Float64Array, + window: usize, +) -> Float64Array { + let a = to_vec(asset); + let b = to_vec(benchmark); + from_vec(ferro_ta_core::portfolio::rolling_beta(&a, &b, window)) +} + +/// Drawdown series and maximum drawdown for an equity curve. +/// Returns `[dd_array, max_dd_as_single_element]`. +#[wasm_bindgen] +pub fn drawdown_series(equity: &Float64Array) -> Array { + let eq = to_vec(equity); + let (dd, max_dd) = ferro_ta_core::portfolio::drawdown_series(&eq); + let out = Array::new(); + out.push(&from_vec(dd)); + out.push(&from_vec(vec![max_dd])); + out +} + +/// Relative strength of asset vs benchmark (cumulative return ratio). +#[wasm_bindgen] +pub fn relative_strength( + asset_returns: &Float64Array, + benchmark_returns: &Float64Array, +) -> Float64Array { + let a = to_vec(asset_returns); + let b = to_vec(benchmark_returns); + from_vec(ferro_ta_core::portfolio::relative_strength(&a, &b)) +} + +/// Spread between two series: a - hedge * b. +#[wasm_bindgen] +pub fn spread(a: &Float64Array, b: &Float64Array, hedge: f64) -> Float64Array { + let av = to_vec(a); + let bv = to_vec(b); + from_vec(ferro_ta_core::portfolio::spread(&av, &bv, hedge)) +} + +/// Ratio between two series: a / b (NaN where b is zero). +#[wasm_bindgen] +pub fn ratio(a: &Float64Array, b: &Float64Array) -> Float64Array { + let av = to_vec(a); + let bv = to_vec(b); + from_vec(ferro_ta_core::portfolio::ratio(&av, &bv)) +} + +/// Rolling Z-score of a 1-D series. +#[wasm_bindgen] +pub fn zscore_series(x: &Float64Array, window: usize) -> Float64Array { + let xv = to_vec(x); + from_vec(ferro_ta_core::portfolio::zscore_series(&xv, window)) +} + +// =========================================================================== +// Attribution (Sprint 2) +// =========================================================================== + +/// Trade-level statistics from trade PnL and hold durations. +/// Returns `[win_rate, avg_win, avg_loss, profit_factor, avg_hold_bars]` as Float64Array. +#[wasm_bindgen] +pub fn trade_stats(pnl: &Float64Array, hold_bars: &Float64Array) -> Array { + let p = to_vec(pnl); + let h = to_vec(hold_bars); + let (win_rate, avg_win, avg_loss, profit_factor, avg_hold) = + ferro_ta_core::attribution::trade_stats(&p, &h); + let out = Array::new(); + out.push(&from_vec(vec![win_rate, avg_win, avg_loss, profit_factor, avg_hold])); + out +} + +/// Group per-bar returns by month index and sum each month's contribution. +/// Returns `[months_as_f64, contributions]`. +#[wasm_bindgen] +pub fn monthly_contribution( + bar_returns: &Float64Array, + month_index: &Float64Array, +) -> Array { + let ret = to_vec(bar_returns); + let mi_f64 = to_vec(month_index); + let mi: Vec = mi_f64.iter().map(|&v| v as i64).collect(); + let (months, contributions) = ferro_ta_core::attribution::monthly_contribution(&ret, &mi); + let months_f64: Vec = months.iter().map(|&m| m as f64).collect(); + let out = Array::new(); + out.push(&from_vec(months_f64)); + out.push(&from_vec(contributions)); + out +} + +/// Attribute per-bar returns to each signal label. +/// Returns `[labels_as_f64, contributions]`. +#[wasm_bindgen] +pub fn signal_attribution( + bar_returns: &Float64Array, + signal_labels: &Float64Array, +) -> Array { + let ret = to_vec(bar_returns); + let sl_f64 = to_vec(signal_labels); + let sl: Vec = sl_f64.iter().map(|&v| v as i64).collect(); + let (labels, contributions) = ferro_ta_core::attribution::signal_attribution(&ret, &sl); + let labels_f64: Vec = labels.iter().map(|&l| l as f64).collect(); + let out = Array::new(); + out.push(&from_vec(labels_f64)); + out.push(&from_vec(contributions)); + out +} + +/// Extract trade PnL and hold durations from positions and strategy returns. +/// Returns `[pnl, hold_durations]`. +#[wasm_bindgen] +pub fn extract_trades( + positions: &Float64Array, + strategy_returns: &Float64Array, +) -> Array { + let pos = to_vec(positions); + let sr = to_vec(strategy_returns); + let (pnl, hold) = ferro_ta_core::attribution::extract_trades(&pos, &sr); + let out = Array::new(); + out.push(&from_vec(pnl)); + out.push(&from_vec(hold)); + out +} + +// =========================================================================== +// Resampling (Sprint 2) +// =========================================================================== + +/// Aggregate OHLCV data into volume bars of a fixed volume threshold. +/// Returns `[open, high, low, close, volume]`. +#[wasm_bindgen] +pub fn volume_bars( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + volume: &Float64Array, + volume_threshold: f64, +) -> Array { + let o = to_vec(open); + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let v = to_vec(volume); + let (ro, rh, rl, rc, rv) = + ferro_ta_core::resampling::volume_bars(&o, &h, &l, &c, &v, volume_threshold); + let out = Array::new(); + out.push(&from_vec(ro)); + out.push(&from_vec(rh)); + out.push(&from_vec(rl)); + out.push(&from_vec(rc)); + out.push(&from_vec(rv)); + out +} + +/// Aggregate OHLCV bars by integer group labels. +/// Returns `[open, high, low, close, volume]`. +#[wasm_bindgen] +pub fn ohlcv_agg( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + volume: &Float64Array, + labels: &Float64Array, +) -> Array { + let o = to_vec(open); + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let v = to_vec(volume); + let lbl_f64 = to_vec(labels); + let lbl: Vec = lbl_f64.iter().map(|&x| x as i64).collect(); + let (ro, rh, rl, rc, rv) = + ferro_ta_core::resampling::ohlcv_agg(&o, &h, &l, &c, &v, &lbl); + let out = Array::new(); + out.push(&from_vec(ro)); + out.push(&from_vec(rh)); + out.push(&from_vec(rl)); + out.push(&from_vec(rc)); + out.push(&from_vec(rv)); + out +} + +// =========================================================================== +// Aggregation (Sprint 2) +// =========================================================================== + +/// Aggregate tick/trade data into tick bars (every N ticks become one bar). +/// Returns `[open, high, low, close, volume]`. +#[wasm_bindgen] +pub fn aggregate_tick_bars( + price: &Float64Array, + size: &Float64Array, + ticks_per_bar: usize, +) -> Array { + let p = to_vec(price); + let s = to_vec(size); + let (o, h, l, c, v) = ferro_ta_core::aggregation::aggregate_tick_bars(&p, &s, ticks_per_bar); + let out = Array::new(); + out.push(&from_vec(o)); + out.push(&from_vec(h)); + out.push(&from_vec(l)); + out.push(&from_vec(c)); + out.push(&from_vec(v)); + out +} + +/// Aggregate tick data into volume bars (fixed volume threshold). +/// Returns `[open, high, low, close, volume]`. +#[wasm_bindgen] +pub fn aggregate_volume_bars_ticks( + price: &Float64Array, + size: &Float64Array, + volume_threshold: f64, +) -> Array { + let p = to_vec(price); + let s = to_vec(size); + let (o, h, l, c, v) = + ferro_ta_core::aggregation::aggregate_volume_bars_ticks(&p, &s, volume_threshold); + let out = Array::new(); + out.push(&from_vec(o)); + out.push(&from_vec(h)); + out.push(&from_vec(l)); + out.push(&from_vec(c)); + out.push(&from_vec(v)); + out +} + +/// Aggregate tick data into time bars using pre-computed integer bucket labels. +/// Returns `[open, high, low, close, volume, labels_as_f64]`. +#[wasm_bindgen] +pub fn aggregate_time_bars( + price: &Float64Array, + size: &Float64Array, + labels: &Float64Array, +) -> Array { + let p = to_vec(price); + let s = to_vec(size); + let lbl_f64 = to_vec(labels); + let lbl: Vec = lbl_f64.iter().map(|&x| x as i64).collect(); + let (o, h, l, c, v, out_labels) = + ferro_ta_core::aggregation::aggregate_time_bars(&p, &s, &lbl); + let labels_out: Vec = out_labels.iter().map(|&x| x as f64).collect(); + let out = Array::new(); + out.push(&from_vec(o)); + out.push(&from_vec(h)); + out.push(&from_vec(l)); + out.push(&from_vec(c)); + out.push(&from_vec(v)); + out.push(&from_vec(labels_out)); + out +} + +// =========================================================================== +// Cycle Indicators +// =========================================================================== + +#[wasm_bindgen] +pub fn ht_trendline(close: &Float64Array) -> Float64Array { + let c = to_vec(close); + from_vec(ferro_ta_core::cycle::ht_trendline(&c)) +} + +#[wasm_bindgen] +pub fn ht_dcperiod(close: &Float64Array) -> Float64Array { + let c = to_vec(close); + from_vec(ferro_ta_core::cycle::ht_dcperiod(&c)) +} + +#[wasm_bindgen] +pub fn ht_dcphase(close: &Float64Array) -> Float64Array { + let c = to_vec(close); + from_vec(ferro_ta_core::cycle::ht_dcphase(&c)) +} + +#[wasm_bindgen] +pub fn ht_phasor(close: &Float64Array) -> Array { + let c = to_vec(close); + let (inphase, quad) = ferro_ta_core::cycle::ht_phasor(&c); + let arr = Array::new(); + arr.push(&from_vec(inphase)); arr.push(&from_vec(quad)); + arr +} + +#[wasm_bindgen] +pub fn ht_sine(close: &Float64Array) -> Array { + let c = to_vec(close); + let (sine, leadsine) = ferro_ta_core::cycle::ht_sine(&c); + let arr = Array::new(); + arr.push(&from_vec(sine)); arr.push(&from_vec(leadsine)); + arr +} + +#[wasm_bindgen] +pub fn ht_trendmode(close: &Float64Array) -> Float64Array { + let c = to_vec(close); + let result = ferro_ta_core::cycle::ht_trendmode(&c); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Volume (additional exports) +// =========================================================================== + +#[wasm_bindgen] +pub fn ad(high: &Float64Array, low: &Float64Array, close: &Float64Array, volume: &Float64Array) -> Float64Array { + let h = to_vec(high); let l = to_vec(low); let c = to_vec(close); let v = to_vec(volume); + from_vec(ferro_ta_core::volume::ad(&h, &l, &c, &v)) +} + +#[wasm_bindgen] +pub fn adosc(high: &Float64Array, low: &Float64Array, close: &Float64Array, volume: &Float64Array, fastperiod: usize, slowperiod: usize) -> Float64Array { + let h = to_vec(high); let l = to_vec(low); let c = to_vec(close); let v = to_vec(volume); + from_vec(ferro_ta_core::volume::adosc(&h, &l, &c, &v, fastperiod, slowperiod)) +} + // --------------------------------------------------------------------------- // WASM tests (run with `wasm-pack test --node`) // ---------------------------------------------------------------------------