From 308c46ab9afc1119362aa8e4d18c55484a2ec782 Mon Sep 17 00:00:00 2001 From: gavindiaz Date: Thu, 9 Jul 2026 05:08:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=A9=E5=B1=95=E6=8C=87=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 24 + Cargo.lock | 833 + Cargo.toml | 38 + LICENSE | 21 + Mt5Bridge使用指南.md | 1101 ++ README.md | 742 + RaptorBT使用手册.md | 991 + backtest_output/atr_stop_rr_curves.csv | 818 + backtest_output/atr_stop_rr_metrics.csv | 43 + backtest_output/atr_stop_rr_trades.csv | 23 + backtest_output/ferro_sar_adx_cci_curves.csv | 818 + backtest_output/ferro_sar_adx_cci_metrics.csv | 43 + backtest_output/multi_strategy_curves.csv | 818 + backtest_output/multi_strategy_metrics.csv | 43 + backtest_output/multi_strategy_trades.csv | 22 + backtest_output/sma_cross_curves.csv | 818 + backtest_output/sma_cross_metrics.csv | 43 + backtest_output/sma_cross_trades.csv | 23 + benches/backtest_benchmark.rs | 123 + ferro-ta-main/.cargo/config.toml | 17 + ferro-ta-main/.devcontainer/devcontainer.json | 35 + ferro-ta-main/.gitignore | 55 + ferro-ta-main/.pre-commit-config.yaml | 44 + ferro-ta-main/CHANGELOG.md | 553 + ferro-ta-main/CODE_OF_CONDUCT.md | 131 + ferro-ta-main/CONTRIBUTING.md | 492 + ferro-ta-main/Cargo.lock | 868 + ferro-ta-main/Cargo.toml | 52 + ferro-ta-main/GOVERNANCE.md | 63 + ferro-ta-main/LICENSE | 21 + ferro-ta-main/Makefile | 72 + ferro-ta-main/PACKAGING.md | 28 + ferro-ta-main/PERFORMANCE_ROADMAP.md | 140 + ferro-ta-main/PLATFORMS.md | 76 + ferro-ta-main/README.md | 139 + ferro-ta-main/RELEASE.md | 212 + ferro-ta-main/SECURITY.md | 43 + ferro-ta-main/TA_LIB_COMPATIBILITY.md | 261 + ferro-ta-main/TROUBLESHOOTING.md | 186 + ferro-ta-main/VERSIONING.md | 124 + ferro-ta-main/api/Dockerfile | 50 + ferro-ta-main/api/main.py | 305 + ferro-ta-main/api/requirements.txt | 6 + ferro-ta-main/benchmarks/README.md | 321 + ferro-ta-main/benchmarks/__init__.py | 1 + .../benchmarks/artifacts/latest/batch.json | 71 + .../latest/bench_backtest_results.json | 153 + .../latest/benchmark_derivatives_compare.json | 1162 ++ .../artifacts/latest/indicator_latency.json | 163 + .../benchmarks/artifacts/latest/manifest.json | 67 + .../artifacts/latest/runtime_hotspots.json | 96 + .../benchmarks/artifacts/latest/simd.json | 285 + .../artifacts/latest/streaming.json | 73 + .../benchmarks/artifacts/latest/wasm.json | 46 + ferro-ta-main/benchmarks/bench_backtest.py | 425 + ferro-ta-main/benchmarks/bench_batch.py | 227 + .../benchmarks/bench_derivatives_compare.py | 1231 ++ ferro-ta-main/benchmarks/bench_gpu.py | 105 + ferro-ta-main/benchmarks/bench_simd.py | 157 + ferro-ta-main/benchmarks/bench_streaming.py | 193 + ferro-ta-main/benchmarks/bench_vs_talib.py | 481 + ferro-ta-main/benchmarks/benchmark_table.py | 108 + .../benchmarks/check_hotspot_regression.py | 107 + .../benchmarks/check_vs_talib_regression.py | 165 + ferro-ta-main/benchmarks/data_generator.py | 68 + .../benchmarks/fixtures/canonical_ohlcv.npz | Bin 0 -> 75586 bytes .../benchmarks/fixtures/generate_canonical.py | 44 + ferro-ta-main/benchmarks/metadata.py | 192 + .../benchmarks/profile_runtime_hotspots.py | 284 + ferro-ta-main/benchmarks/results.json | 16097 ++++++++++++++++ ferro-ta-main/benchmarks/run_perf_contract.py | 210 + ferro-ta-main/benchmarks/test_accuracy.py | 200 + .../benchmarks/test_benchmark_suite.py | 370 + .../benchmarks/test_derivatives_speed.py | 124 + ferro-ta-main/benchmarks/test_speed.py | 102 + ferro-ta-main/benchmarks/wrapper_registry.py | 2822 +++ ferro-ta-main/conda/meta.yaml | 53 + ferro-ta-main/crates/ferro_ta_core/Cargo.toml | 39 + ferro-ta-main/crates/ferro_ta_core/README.md | 87 + .../ferro_ta_core/benches/indicators.rs | 212 + .../crates/ferro_ta_core/src/aggregation.rs | 340 + .../crates/ferro_ta_core/src/alerts.rs | 126 + .../crates/ferro_ta_core/src/attribution.rs | 333 + .../crates/ferro_ta_core/src/backtest.rs | 2123 ++ .../crates/ferro_ta_core/src/batch.rs | 641 + .../crates/ferro_ta_core/src/chunked.rs | 123 + .../crates/ferro_ta_core/src/commission.rs | 295 + .../crates/ferro_ta_core/src/crypto.rs | 91 + .../crates/ferro_ta_core/src/currency.rs | 173 + .../crates/ferro_ta_core/src/cycle.rs | 370 + .../crates/ferro_ta_core/src/extended.rs | 962 + .../crates/ferro_ta_core/src/futures/basis.rs | 55 + .../crates/ferro_ta_core/src/futures/curve.rs | 83 + .../crates/ferro_ta_core/src/futures/mod.rs | 6 + .../crates/ferro_ta_core/src/futures/roll.rs | 109 + .../ferro_ta_core/src/futures/synthetic.rs | 78 + ferro-ta-main/crates/ferro_ta_core/src/lib.rs | 59 + .../crates/ferro_ta_core/src/math.rs | 217 + .../crates/ferro_ta_core/src/math_ops.rs | 154 + .../crates/ferro_ta_core/src/momentum.rs | 925 + .../ferro_ta_core/src/options/american.rs | 410 + .../crates/ferro_ta_core/src/options/chain.rs | 162 + .../ferro_ta_core/src/options/digital.rs | 382 + .../ferro_ta_core/src/options/greeks.rs | 327 + .../crates/ferro_ta_core/src/options/iv.rs | 241 + .../crates/ferro_ta_core/src/options/mod.rs | 102 + .../ferro_ta_core/src/options/normal.rs | 44 + .../ferro_ta_core/src/options/payoff.rs | 392 + .../ferro_ta_core/src/options/pricing.rs | 218 + .../ferro_ta_core/src/options/realized_vol.rs | 445 + .../ferro_ta_core/src/options/surface.rs | 269 + .../crates/ferro_ta_core/src/overlap.rs | 1054 + .../crates/ferro_ta_core/src/pattern.rs | 1806 ++ .../crates/ferro_ta_core/src/portfolio.rs | 631 + .../ferro_ta_core/src/price_transform.rs | 89 + .../crates/ferro_ta_core/src/regime.rs | 166 + .../crates/ferro_ta_core/src/resampling.rs | 277 + .../crates/ferro_ta_core/src/signals.rs | 131 + .../crates/ferro_ta_core/src/simd.rs | 161 + .../crates/ferro_ta_core/src/statistic.rs | 492 + .../crates/ferro_ta_core/src/streaming.rs | 946 + .../crates/ferro_ta_core/src/volatility.rs | 95 + .../crates/ferro_ta_core/src/volume.rs | 193 + ferro-ta-main/deny.toml | 76 + ferro-ta-main/docs/_static/.gitkeep | 0 ferro-ta-main/docs/adjacent_tooling.rst | 121 + ferro-ta-main/docs/agentic.md | 203 + ferro-ta-main/docs/api/analysis.rst | 22 + ferro-ta-main/docs/api/batch.rst | 7 + ferro-ta-main/docs/api/cycle.rst | 7 + ferro-ta-main/docs/api/exceptions.rst | 8 + ferro-ta-main/docs/api/extended.rst | 7 + ferro-ta-main/docs/api/index.rst | 20 + ferro-ta-main/docs/api/math_ops.rst | 7 + ferro-ta-main/docs/api/momentum.rst | 7 + ferro-ta-main/docs/api/overlap.rst | 7 + ferro-ta-main/docs/api/pattern.rst | 7 + ferro-ta-main/docs/api/price_transform.rst | 7 + ferro-ta-main/docs/api/statistic.rst | 7 + ferro-ta-main/docs/api/streaming.rst | 7 + ferro-ta-main/docs/api/volatility.rst | 7 + ferro-ta-main/docs/api/volume.rst | 7 + ferro-ta-main/docs/api_manifest.json | 7118 +++++++ ferro-ta-main/docs/architecture.md | 176 + ferro-ta-main/docs/batch.rst | 42 + ferro-ta-main/docs/benchmarks.rst | 246 + ferro-ta-main/docs/changelog.rst | 271 + ferro-ta-main/docs/compatibility/finta.md | 145 + ferro-ta-main/docs/compatibility/pandas_ta.md | 108 + ferro-ta-main/docs/compatibility/ta.md | 104 + ferro-ta-main/docs/compatibility/talib.md | 27 + ferro-ta-main/docs/compatibility/tulipy.md | 140 + ferro-ta-main/docs/conf.py | 98 + ferro-ta-main/docs/contributing.rst | 113 + ferro-ta-main/docs/derivatives-analytics.md | 226 + ferro-ta-main/docs/derivatives.rst | 126 + ferro-ta-main/docs/error_handling.rst | 79 + ferro-ta-main/docs/extended.rst | 10 + ferro-ta-main/docs/gpu-backend.md | 134 + ferro-ta-main/docs/guides/dtw.md | 80 + ferro-ta-main/docs/guides/simd.md | 92 + ferro-ta-main/docs/index.rst | 108 + ferro-ta-main/docs/mcp.md | 191 + ferro-ta-main/docs/migration_talib.rst | 168 + ferro-ta-main/docs/options-volatility.md | 79 + ferro-ta-main/docs/out-of-core.md | 169 + ferro-ta-main/docs/pandas_api.rst | 46 + ferro-ta-main/docs/performance.md | 397 + ferro-ta-main/docs/plugin-catalog.md | 85 + ferro-ta-main/docs/plugins.rst | 91 + ferro-ta-main/docs/quickstart.rst | 119 + ferro-ta-main/docs/rust_first.md | 213 + ferro-ta-main/docs/stability.md | 104 + ferro-ta-main/docs/streaming.rst | 12 + ferro-ta-main/docs/support_matrix.rst | 190 + ferro-ta-main/examples/README.md | 31 + ferro-ta-main/examples/backtesting.ipynb | 200 + ferro-ta-main/examples/custom_indicator.py | 53 + ferro-ta-main/examples/quickstart.ipynb | 234 + ferro-ta-main/examples/streaming.ipynb | 206 + ferro-ta-main/fuzz/Cargo.toml | 81 + ferro-ta-main/fuzz/fuzz_targets/fuzz_atr.rs | 48 + .../fuzz/fuzz_targets/fuzz_bbands.rs | 60 + ferro-ta-main/fuzz/fuzz_targets/fuzz_ema.rs | 35 + ferro-ta-main/fuzz/fuzz_targets/fuzz_macd.rs | 41 + ferro-ta-main/fuzz/fuzz_targets/fuzz_mfi.rs | 51 + ferro-ta-main/fuzz/fuzz_targets/fuzz_rsi.rs | 52 + ferro-ta-main/fuzz/fuzz_targets/fuzz_sma.rs | 51 + ferro-ta-main/fuzz/fuzz_targets/fuzz_stoch.rs | 63 + ferro-ta-main/fuzz/fuzz_targets/fuzz_wma.rs | 35 + ferro-ta-main/perf-contract/batch.json | 93 + .../perf-contract/indicator_latency.json | 185 + ferro-ta-main/perf-contract/manifest.json | 69 + .../perf-contract/runtime_hotspots.json | 118 + ferro-ta-main/perf-contract/simd.json | 285 + ferro-ta-main/perf-contract/streaming.json | 95 + ferro-ta-main/pyproject.toml | 167 + ferro-ta-main/python/ferro_ta/__init__.py | 674 + ferro-ta-main/python/ferro_ta/__init__.pyi | 765 + ferro-ta-main/python/ferro_ta/_binding.py | 93 + .../python/ferro_ta/_indicator_manifest.yaml | 364 + ferro-ta-main/python/ferro_ta/_utils.py | 291 + .../python/ferro_ta/analysis/__init__.py | 62 + .../python/ferro_ta/analysis/adjust.py | 194 + .../python/ferro_ta/analysis/attribution.py | 329 + .../python/ferro_ta/analysis/backtest.py | 1535 ++ .../python/ferro_ta/analysis/cross_asset.py | 237 + .../python/ferro_ta/analysis/crypto.py | 232 + .../ferro_ta/analysis/derivatives_payoff.py | 357 + .../python/ferro_ta/analysis/features.py | 184 + .../python/ferro_ta/analysis/futures.py | 230 + .../python/ferro_ta/analysis/live.py | 544 + .../python/ferro_ta/analysis/multitf.py | 185 + .../python/ferro_ta/analysis/optimize.py | 318 + .../python/ferro_ta/analysis/options.py | 1595 ++ .../ferro_ta/analysis/options_strategy.py | 326 + .../python/ferro_ta/analysis/plot.py | 277 + .../python/ferro_ta/analysis/portfolio.py | 240 + .../python/ferro_ta/analysis/regime.py | 594 + .../python/ferro_ta/analysis/resample.py | 139 + .../python/ferro_ta/analysis/signals.py | 222 + .../python/ferro_ta/core/__init__.py | 16 + ferro-ta-main/python/ferro_ta/core/config.py | 257 + .../python/ferro_ta/core/exceptions.py | 337 + .../python/ferro_ta/core/logging_utils.py | 328 + ferro-ta-main/python/ferro_ta/core/raw.py | 391 + .../python/ferro_ta/core/registry.py | 199 + .../python/ferro_ta/data/__init__.py | 17 + .../python/ferro_ta/data/adapters.py | 271 + .../python/ferro_ta/data/aggregation.py | 238 + ferro-ta-main/python/ferro_ta/data/batch.py | 446 + ferro-ta-main/python/ferro_ta/data/chunked.py | 251 + .../python/ferro_ta/data/resampling.py | 278 + .../python/ferro_ta/data/streaming.py | 69 + .../python/ferro_ta/indicators/__init__.py | 25 + .../python/ferro_ta/indicators/cycle.py | 187 + .../python/ferro_ta/indicators/extended.py | 498 + .../python/ferro_ta/indicators/math_ops.py | 372 + .../python/ferro_ta/indicators/momentum.py | 908 + .../python/ferro_ta/indicators/overlap.py | 656 + .../python/ferro_ta/indicators/pattern.py | 1959 ++ .../ferro_ta/indicators/price_transform.py | 130 + .../python/ferro_ta/indicators/statistic.py | 369 + .../python/ferro_ta/indicators/volatility.py | 116 + .../python/ferro_ta/indicators/volume.py | 123 + .../python/ferro_ta/logging_utils.py | 8 + ferro-ta-main/python/ferro_ta/mcp/__init__.py | 1348 ++ ferro-ta-main/python/ferro_ta/mcp/__main__.py | 6 + ferro-ta-main/python/ferro_ta/py.typed | 0 .../python/ferro_ta/tools/__init__.py | 29 + ferro-ta-main/python/ferro_ta/tools/alerts.py | 432 + .../python/ferro_ta/tools/api_info.py | 300 + .../python/ferro_ta/tools/dashboard.py | 345 + ferro-ta-main/python/ferro_ta/tools/dsl.py | 525 + ferro-ta-main/python/ferro_ta/tools/gpu.py | 224 + .../python/ferro_ta/tools/pipeline.py | 343 + ferro-ta-main/python/ferro_ta/tools/tools.py | 284 + ferro-ta-main/python/ferro_ta/tools/viz.py | 351 + .../python/ferro_ta/tools/workflow.py | 333 + ferro-ta-main/python/ferro_ta/utils.py | 9 + ferro-ta-main/scripts/build_api_manifest.py | 294 + ferro-ta-main/scripts/bump_version.py | 186 + ferro-ta-main/scripts/check_api_manifest.py | 53 + ferro-ta-main/scripts/check_changelog.py | 36 + ferro-ta-main/scripts/pre_push_checks.sh | 265 + ferro-ta-main/src/aggregation/mod.rs | 119 + ferro-ta-main/src/alerts/mod.rs | 73 + ferro-ta-main/src/attribution/mod.rs | 79 + ferro-ta-main/src/backtest/commission.rs | 294 + ferro-ta-main/src/backtest/currency.rs | 133 + ferro-ta-main/src/backtest/mod.rs | 821 + ferro-ta-main/src/batch/mod.rs | 450 + ferro-ta-main/src/chunked/mod.rs | 152 + ferro-ta-main/src/crypto/mod.rs | 55 + ferro-ta-main/src/cycle/ht_dcperiod.rs | 12 + ferro-ta-main/src/cycle/ht_dcphase.rs | 12 + ferro-ta-main/src/cycle/ht_phasor.rs | 13 + ferro-ta-main/src/cycle/ht_sine.rs | 13 + ferro-ta-main/src/cycle/ht_trendline.rs | 12 + ferro-ta-main/src/cycle/ht_trendmode.rs | 12 + ferro-ta-main/src/cycle/mod.rs | 23 + ferro-ta-main/src/extended/mod.rs | 315 + ferro-ta-main/src/futures/basis.rs | 34 + ferro-ta-main/src/futures/curve.rs | 52 + ferro-ta-main/src/futures/mod.rs | 38 + ferro-ta-main/src/futures/roll.rs | 75 + ferro-ta-main/src/futures/synthetic.rs | 60 + ferro-ta-main/src/lib.rs | 76 + ferro-ta-main/src/math_ops/mod.rs | 84 + ferro-ta-main/src/momentum/adx.rs | 205 + ferro-ta-main/src/momentum/apo.rs | 40 + ferro-ta-main/src/momentum/aroon.rs | 87 + ferro-ta-main/src/momentum/bop.rs | 35 + ferro-ta-main/src/momentum/cci.rs | 43 + ferro-ta-main/src/momentum/cmo.rs | 43 + ferro-ta-main/src/momentum/mfi.rs | 31 + ferro-ta-main/src/momentum/mod.rs | 54 + ferro-ta-main/src/momentum/mom.rs | 21 + ferro-ta-main/src/momentum/ppo.rs | 52 + ferro-ta-main/src/momentum/roc.rs | 92 + ferro-ta-main/src/momentum/rsi.rs | 19 + ferro-ta-main/src/momentum/stoch.rs | 40 + ferro-ta-main/src/momentum/stochf.rs | 62 + ferro-ta-main/src/momentum/stochrsi.rs | 106 + ferro-ta-main/src/momentum/trix.rs | 51 + ferro-ta-main/src/momentum/ultosc.rs | 73 + ferro-ta-main/src/momentum/willr.rs | 44 + ferro-ta-main/src/options/american.rs | 127 + ferro-ta-main/src/options/chain.rs | 64 + ferro-ta-main/src/options/digital.rs | 164 + ferro-ta-main/src/options/greeks.rs | 242 + ferro-ta-main/src/options/iv.rs | 149 + ferro-ta-main/src/options/mod.rs | 148 + ferro-ta-main/src/options/payoff.rs | 495 + ferro-ta-main/src/options/pricing.rs | 151 + ferro-ta-main/src/options/realized_vol.rs | 115 + ferro-ta-main/src/options/surface.rs | 65 + ferro-ta-main/src/overlap/bbands.rs | 30 + ferro-ta-main/src/overlap/dema.rs | 40 + ferro-ta-main/src/overlap/ema.rs | 19 + ferro-ta-main/src/overlap/kama.rs | 43 + ferro-ta-main/src/overlap/ma_mavp.rs | 58 + ferro-ta-main/src/overlap/macd.rs | 57 + ferro-ta-main/src/overlap/macdext.rs | 116 + ferro-ta-main/src/overlap/mama.rs | 138 + ferro-ta-main/src/overlap/midpoint.rs | 30 + ferro-ta-main/src/overlap/midprice.rs | 34 + ferro-ta-main/src/overlap/mod.rs | 47 + ferro-ta-main/src/overlap/sar.rs | 70 + ferro-ta-main/src/overlap/sarext.rs | 103 + ferro-ta-main/src/overlap/sma.rs | 29 + ferro-ta-main/src/overlap/t3.rs | 46 + ferro-ta-main/src/overlap/tema.rs | 45 + ferro-ta-main/src/overlap/trima.rs | 34 + ferro-ta-main/src/overlap/wma.rs | 19 + ferro-ta-main/src/pattern/cdl2crows.rs | 18 + ferro-ta-main/src/pattern/cdl3blackcrows.rs | 18 + ferro-ta-main/src/pattern/cdl3inside.rs | 18 + ferro-ta-main/src/pattern/cdl3linestrike.rs | 18 + ferro-ta-main/src/pattern/cdl3outside.rs | 18 + ferro-ta-main/src/pattern/cdl3starsinsouth.rs | 18 + .../src/pattern/cdl3whitesoldiers.rs | 18 + ferro-ta-main/src/pattern/cdlabandonedbaby.rs | 18 + ferro-ta-main/src/pattern/cdladvanceblock.rs | 18 + ferro-ta-main/src/pattern/cdlbelthold.rs | 18 + ferro-ta-main/src/pattern/cdlbreakaway.rs | 18 + .../src/pattern/cdlclosingmarubozu.rs | 18 + .../src/pattern/cdlconcealbabyswall.rs | 18 + ferro-ta-main/src/pattern/cdlcounterattack.rs | 18 + .../src/pattern/cdldarkcloudcover.rs | 18 + ferro-ta-main/src/pattern/cdldoji.rs | 18 + ferro-ta-main/src/pattern/cdldojistar.rs | 18 + ferro-ta-main/src/pattern/cdldragonflydoji.rs | 18 + ferro-ta-main/src/pattern/cdlengulfing.rs | 18 + .../src/pattern/cdleveningdojistar.rs | 18 + ferro-ta-main/src/pattern/cdleveningstar.rs | 18 + .../src/pattern/cdlgapsidesidewhite.rs | 18 + .../src/pattern/cdlgravestonedoji.rs | 18 + ferro-ta-main/src/pattern/cdlhammer.rs | 18 + ferro-ta-main/src/pattern/cdlhangingman.rs | 18 + ferro-ta-main/src/pattern/cdlharami.rs | 18 + ferro-ta-main/src/pattern/cdlharamicross.rs | 18 + ferro-ta-main/src/pattern/cdlhighwave.rs | 18 + ferro-ta-main/src/pattern/cdlhikkake.rs | 18 + ferro-ta-main/src/pattern/cdlhikkakemod.rs | 18 + ferro-ta-main/src/pattern/cdlhomingpigeon.rs | 18 + .../src/pattern/cdlidentical3crows.rs | 18 + ferro-ta-main/src/pattern/cdlinneck.rs | 18 + .../src/pattern/cdlinvertedhammer.rs | 18 + ferro-ta-main/src/pattern/cdlkicking.rs | 18 + .../src/pattern/cdlkickingbylength.rs | 18 + ferro-ta-main/src/pattern/cdlladderbottom.rs | 18 + .../src/pattern/cdllongleggeddoji.rs | 18 + ferro-ta-main/src/pattern/cdllongline.rs | 18 + ferro-ta-main/src/pattern/cdlmarubozu.rs | 18 + ferro-ta-main/src/pattern/cdlmatchinglow.rs | 18 + ferro-ta-main/src/pattern/cdlmathold.rs | 18 + .../src/pattern/cdlmorningdojistar.rs | 18 + ferro-ta-main/src/pattern/cdlmorningstar.rs | 18 + ferro-ta-main/src/pattern/cdlonneck.rs | 18 + ferro-ta-main/src/pattern/cdlpiercing.rs | 18 + ferro-ta-main/src/pattern/cdlrickshawman.rs | 18 + .../src/pattern/cdlrisefall3methods.rs | 18 + .../src/pattern/cdlseparatinglines.rs | 18 + ferro-ta-main/src/pattern/cdlshootingstar.rs | 18 + ferro-ta-main/src/pattern/cdlshortline.rs | 18 + ferro-ta-main/src/pattern/cdlspinningtop.rs | 18 + .../src/pattern/cdlstalledpattern.rs | 18 + ferro-ta-main/src/pattern/cdlsticksandwich.rs | 18 + ferro-ta-main/src/pattern/cdltakuri.rs | 18 + ferro-ta-main/src/pattern/cdltasukigap.rs | 18 + ferro-ta-main/src/pattern/cdlthrusting.rs | 18 + ferro-ta-main/src/pattern/cdltristar.rs | 18 + ferro-ta-main/src/pattern/cdlunique3river.rs | 18 + .../src/pattern/cdlupsidegap2crows.rs | 18 + .../src/pattern/cdlxsidegap3methods.rs | 18 + ferro-ta-main/src/pattern/common.rs | 1 + ferro-ta-main/src/pattern/mod.rs | 243 + ferro-ta-main/src/portfolio/mod.rs | 371 + ferro-ta-main/src/price_transform/avgprice.rs | 27 + ferro-ta-main/src/price_transform/medprice.rs | 18 + ferro-ta-main/src/price_transform/mod.rs | 17 + ferro-ta-main/src/price_transform/typprice.rs | 24 + ferro-ta-main/src/price_transform/wclprice.rs | 24 + ferro-ta-main/src/regime/mod.rs | 80 + ferro-ta-main/src/resampling/mod.rs | 90 + ferro-ta-main/src/signals/mod.rs | 78 + ferro-ta-main/src/statistic/beta.rs | 146 + ferro-ta-main/src/statistic/common.rs | 70 + ferro-ta-main/src/statistic/correl.rs | 101 + ferro-ta-main/src/statistic/dtw.rs | 98 + ferro-ta-main/src/statistic/linearreg.rs | 81 + ferro-ta-main/src/statistic/mod.rs | 31 + ferro-ta-main/src/statistic/stddev.rs | 30 + ferro-ta-main/src/statistic/var.rs | 26 + ferro-ta-main/src/streaming/mod.rs | 385 + ferro-ta-main/src/validation.rs | 57 + ferro-ta-main/src/volatility/atr.rs | 31 + ferro-ta-main/src/volatility/common.rs | 17 + ferro-ta-main/src/volatility/mod.rs | 15 + ferro-ta-main/src/volatility/natr.rs | 34 + ferro-ta-main/src/volatility/trange.rs | 34 + ferro-ta-main/src/volume/ad.rs | 27 + ferro-ta-main/src/volume/adosc.rs | 38 + ferro-ta-main/src/volume/mod.rs | 15 + ferro-ta-main/src/volume/obv.rs | 18 + ferro-ta-main/tests/conftest.py | 101 + ferro-ta-main/tests/fixtures/ohlcv_daily.csv | 253 + ferro-ta-main/tests/integration/conftest.py | 7 + .../test_cross_surface_manifest.py | 24 + .../tests/integration/test_integration.py | 341 + .../integration/test_streaming_accuracy.py | 540 + .../tests/integration/test_vs_pandas_ta.py | 711 + ferro-ta-main/tests/integration/test_vs_ta.py | 291 + .../tests/integration/test_vs_talib.py | 2178 +++ .../integration/test_wasm_node_conformance.py | 184 + ferro-ta-main/tests/unit/analysis/__init__.py | 0 .../unit/analysis/test_backtest_advanced.py | 2017 ++ .../tests/unit/analysis/test_backtest_v2.py | 546 + ferro-ta-main/tests/unit/conftest.py | 7 + ferro-ta-main/tests/unit/helpers.py | 159 + .../tests/unit/indicators/__init__.py | 0 .../tests/unit/indicators/test_cycle.py | 183 + .../tests/unit/indicators/test_extended.py | 292 + .../tests/unit/indicators/test_math_ops.py | 313 + .../tests/unit/indicators/test_momentum.py | 588 + .../tests/unit/indicators/test_overlap.py | 484 + .../tests/unit/indicators/test_pattern.py | 260 + .../unit/indicators/test_price_transform.py | 113 + .../tests/unit/indicators/test_statistic.py | 488 + .../tests/unit/indicators/test_volatility.py | 125 + .../tests/unit/indicators/test_volume.py | 118 + .../tests/unit/streaming/__init__.py | 0 .../tests/unit/streaming/test_streaming.py | 387 + ferro-ta-main/tests/unit/test_coverage.py | 2513 +++ .../tests/unit/test_data_pipeline.py | 777 + .../tests/unit/test_dataframe_integration.py | 224 + ferro-ta-main/tests/unit/test_derivatives.py | 651 + .../tests/unit/test_derivatives_accuracy.py | 608 + ferro-ta-main/tests/unit/test_edge_cases.py | 296 + ferro-ta-main/tests/unit/test_ferro_ta.py | 2986 +++ .../tests/unit/test_infrastructure.py | 1060 + ferro-ta-main/tests/unit/test_known_values.py | 562 + .../tests/unit/test_math_ops_vs_numpy.py | 357 + .../unit/test_optional_dependency_wrappers.py | 57 + .../tests/unit/test_property_based.py | 263 + .../tests/unit/test_tools_and_api.py | 1519 ++ ferro-ta-main/tests/unit/test_validation.py | 155 + ferro-ta-main/tests/unit/tools/__init__.py | 0 ferro-ta-main/uv.lock | 4456 +++++ ferro-ta-main/wasm/Cargo.lock | 420 + ferro-ta-main/wasm/Cargo.toml | 23 + ferro-ta-main/wasm/README.md | 125 + ferro-ta-main/wasm/bench.js | 118 + ferro-ta-main/wasm/package.json | 41 + ferro-ta-main/wasm/src/lib.rs | 3607 ++++ my_indicators.py | 226 + pyproject.toml | 50 + python/raptorbt/__init__.py | 257 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 3157 bytes python/raptorbt/_raptorbt.cp312-win_amd64.pyd | Bin 0 -> 1453568 bytes .../raptorbt/_raptorbt.cpython-311-darwin.so | Bin 0 -> 872752 bytes requirements.txt | 3 + rustfmt.toml | 6 + src/core/error.rs | 80 + src/core/mod.rs | 11 + src/core/session.rs | 397 + src/core/timeseries.rs | 301 + src/core/types.rs | 595 + src/execution/fees.rs | 157 + src/execution/fill.rs | 361 + src/execution/mod.rs | 9 + src/execution/slippage.rs | 204 + src/indicators/ferro_bridge.rs | 847 + src/indicators/mod.rs | 36 + src/indicators/momentum.rs | 147 + src/indicators/rolling.rs | 106 + src/indicators/strength.rs | 104 + src/indicators/tick_features.rs | 246 + src/indicators/trend.rs | 236 + src/indicators/volatility.rs | 179 + src/indicators/volume.rs | 249 + src/lib.rs | 160 + src/metrics/drawdown.rs | 344 + src/metrics/mod.rs | 9 + src/metrics/streaming.rs | 756 + src/metrics/trade_stats.rs | 350 + src/portfolio/allocation.rs | 340 + src/portfolio/engine.rs | 902 + src/portfolio/mod.rs | 11 + src/portfolio/monte_carlo.rs | 361 + src/portfolio/position.rs | 347 + src/python/bindings.rs | 2751 +++ src/python/mod.rs | 4 + src/python/numpy_bridge.rs | 34 + src/signals/expression.rs | 456 + src/signals/mod.rs | 12 + src/signals/processor.rs | 427 + src/signals/synchronizer.rs | 385 + src/signals/tick_signals.rs | 174 + src/stops/atr.rs | 237 + src/stops/fixed.rs | 168 + src/stops/mod.rs | 38 + src/stops/trailing.rs | 395 + src/strategies/basket.rs | 505 + src/strategies/mod.rs | 19 + src/strategies/multi.rs | 378 + src/strategies/options.rs | 430 + src/strategies/pairs.rs | 453 + src/strategies/single.rs | 220 + src/strategies/spreads.rs | 606 + src/strategies/tick.rs | 359 + test_with_mt5.py | 1090 ++ tests/test_indicators.rs | 484 + tests/test_portfolio.rs | 309 + uv.lock | 8 + 指标扩展.md | 246 + 537 files changed, 152299 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Mt5Bridge使用指南.md create mode 100644 README.md create mode 100644 RaptorBT使用手册.md create mode 100644 backtest_output/atr_stop_rr_curves.csv create mode 100644 backtest_output/atr_stop_rr_metrics.csv create mode 100644 backtest_output/atr_stop_rr_trades.csv create mode 100644 backtest_output/ferro_sar_adx_cci_curves.csv create mode 100644 backtest_output/ferro_sar_adx_cci_metrics.csv create mode 100644 backtest_output/multi_strategy_curves.csv create mode 100644 backtest_output/multi_strategy_metrics.csv create mode 100644 backtest_output/multi_strategy_trades.csv create mode 100644 backtest_output/sma_cross_curves.csv create mode 100644 backtest_output/sma_cross_metrics.csv create mode 100644 backtest_output/sma_cross_trades.csv create mode 100644 benches/backtest_benchmark.rs create mode 100644 ferro-ta-main/.cargo/config.toml create mode 100644 ferro-ta-main/.devcontainer/devcontainer.json create mode 100644 ferro-ta-main/.gitignore create mode 100644 ferro-ta-main/.pre-commit-config.yaml create mode 100644 ferro-ta-main/CHANGELOG.md create mode 100644 ferro-ta-main/CODE_OF_CONDUCT.md create mode 100644 ferro-ta-main/CONTRIBUTING.md create mode 100644 ferro-ta-main/Cargo.lock create mode 100644 ferro-ta-main/Cargo.toml create mode 100644 ferro-ta-main/GOVERNANCE.md create mode 100644 ferro-ta-main/LICENSE create mode 100644 ferro-ta-main/Makefile create mode 100644 ferro-ta-main/PACKAGING.md create mode 100644 ferro-ta-main/PERFORMANCE_ROADMAP.md create mode 100644 ferro-ta-main/PLATFORMS.md create mode 100644 ferro-ta-main/README.md create mode 100644 ferro-ta-main/RELEASE.md create mode 100644 ferro-ta-main/SECURITY.md create mode 100644 ferro-ta-main/TA_LIB_COMPATIBILITY.md create mode 100644 ferro-ta-main/TROUBLESHOOTING.md create mode 100644 ferro-ta-main/VERSIONING.md create mode 100644 ferro-ta-main/api/Dockerfile create mode 100644 ferro-ta-main/api/main.py create mode 100644 ferro-ta-main/api/requirements.txt create mode 100644 ferro-ta-main/benchmarks/README.md create mode 100644 ferro-ta-main/benchmarks/__init__.py create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/batch.json create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/bench_backtest_results.json create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/benchmark_derivatives_compare.json create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/indicator_latency.json create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/manifest.json create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/runtime_hotspots.json create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/simd.json create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/streaming.json create mode 100644 ferro-ta-main/benchmarks/artifacts/latest/wasm.json create mode 100644 ferro-ta-main/benchmarks/bench_backtest.py create mode 100644 ferro-ta-main/benchmarks/bench_batch.py create mode 100644 ferro-ta-main/benchmarks/bench_derivatives_compare.py create mode 100644 ferro-ta-main/benchmarks/bench_gpu.py create mode 100644 ferro-ta-main/benchmarks/bench_simd.py create mode 100644 ferro-ta-main/benchmarks/bench_streaming.py create mode 100644 ferro-ta-main/benchmarks/bench_vs_talib.py create mode 100644 ferro-ta-main/benchmarks/benchmark_table.py create mode 100644 ferro-ta-main/benchmarks/check_hotspot_regression.py create mode 100644 ferro-ta-main/benchmarks/check_vs_talib_regression.py create mode 100644 ferro-ta-main/benchmarks/data_generator.py create mode 100644 ferro-ta-main/benchmarks/fixtures/canonical_ohlcv.npz create mode 100644 ferro-ta-main/benchmarks/fixtures/generate_canonical.py create mode 100644 ferro-ta-main/benchmarks/metadata.py create mode 100644 ferro-ta-main/benchmarks/profile_runtime_hotspots.py create mode 100644 ferro-ta-main/benchmarks/results.json create mode 100644 ferro-ta-main/benchmarks/run_perf_contract.py create mode 100644 ferro-ta-main/benchmarks/test_accuracy.py create mode 100644 ferro-ta-main/benchmarks/test_benchmark_suite.py create mode 100644 ferro-ta-main/benchmarks/test_derivatives_speed.py create mode 100644 ferro-ta-main/benchmarks/test_speed.py create mode 100644 ferro-ta-main/benchmarks/wrapper_registry.py create mode 100644 ferro-ta-main/conda/meta.yaml create mode 100644 ferro-ta-main/crates/ferro_ta_core/Cargo.toml create mode 100644 ferro-ta-main/crates/ferro_ta_core/README.md create mode 100644 ferro-ta-main/crates/ferro_ta_core/benches/indicators.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/aggregation.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/alerts.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/attribution.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/backtest.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/batch.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/chunked.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/commission.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/crypto.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/currency.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/cycle.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/extended.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/futures/basis.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/futures/curve.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/futures/mod.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/futures/roll.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/futures/synthetic.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/lib.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/math.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/math_ops.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/momentum.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/american.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/chain.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/digital.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/greeks.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/iv.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/mod.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/normal.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/payoff.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/pricing.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/realized_vol.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/options/surface.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/overlap.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/pattern.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/portfolio.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/price_transform.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/regime.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/resampling.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/signals.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/simd.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/statistic.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/streaming.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/volatility.rs create mode 100644 ferro-ta-main/crates/ferro_ta_core/src/volume.rs create mode 100644 ferro-ta-main/deny.toml create mode 100644 ferro-ta-main/docs/_static/.gitkeep create mode 100644 ferro-ta-main/docs/adjacent_tooling.rst create mode 100644 ferro-ta-main/docs/agentic.md create mode 100644 ferro-ta-main/docs/api/analysis.rst create mode 100644 ferro-ta-main/docs/api/batch.rst create mode 100644 ferro-ta-main/docs/api/cycle.rst create mode 100644 ferro-ta-main/docs/api/exceptions.rst create mode 100644 ferro-ta-main/docs/api/extended.rst create mode 100644 ferro-ta-main/docs/api/index.rst create mode 100644 ferro-ta-main/docs/api/math_ops.rst create mode 100644 ferro-ta-main/docs/api/momentum.rst create mode 100644 ferro-ta-main/docs/api/overlap.rst create mode 100644 ferro-ta-main/docs/api/pattern.rst create mode 100644 ferro-ta-main/docs/api/price_transform.rst create mode 100644 ferro-ta-main/docs/api/statistic.rst create mode 100644 ferro-ta-main/docs/api/streaming.rst create mode 100644 ferro-ta-main/docs/api/volatility.rst create mode 100644 ferro-ta-main/docs/api/volume.rst create mode 100644 ferro-ta-main/docs/api_manifest.json create mode 100644 ferro-ta-main/docs/architecture.md create mode 100644 ferro-ta-main/docs/batch.rst create mode 100644 ferro-ta-main/docs/benchmarks.rst create mode 100644 ferro-ta-main/docs/changelog.rst create mode 100644 ferro-ta-main/docs/compatibility/finta.md create mode 100644 ferro-ta-main/docs/compatibility/pandas_ta.md create mode 100644 ferro-ta-main/docs/compatibility/ta.md create mode 100644 ferro-ta-main/docs/compatibility/talib.md create mode 100644 ferro-ta-main/docs/compatibility/tulipy.md create mode 100644 ferro-ta-main/docs/conf.py create mode 100644 ferro-ta-main/docs/contributing.rst create mode 100644 ferro-ta-main/docs/derivatives-analytics.md create mode 100644 ferro-ta-main/docs/derivatives.rst create mode 100644 ferro-ta-main/docs/error_handling.rst create mode 100644 ferro-ta-main/docs/extended.rst create mode 100644 ferro-ta-main/docs/gpu-backend.md create mode 100644 ferro-ta-main/docs/guides/dtw.md create mode 100644 ferro-ta-main/docs/guides/simd.md create mode 100644 ferro-ta-main/docs/index.rst create mode 100644 ferro-ta-main/docs/mcp.md create mode 100644 ferro-ta-main/docs/migration_talib.rst create mode 100644 ferro-ta-main/docs/options-volatility.md create mode 100644 ferro-ta-main/docs/out-of-core.md create mode 100644 ferro-ta-main/docs/pandas_api.rst create mode 100644 ferro-ta-main/docs/performance.md create mode 100644 ferro-ta-main/docs/plugin-catalog.md create mode 100644 ferro-ta-main/docs/plugins.rst create mode 100644 ferro-ta-main/docs/quickstart.rst create mode 100644 ferro-ta-main/docs/rust_first.md create mode 100644 ferro-ta-main/docs/stability.md create mode 100644 ferro-ta-main/docs/streaming.rst create mode 100644 ferro-ta-main/docs/support_matrix.rst create mode 100644 ferro-ta-main/examples/README.md create mode 100644 ferro-ta-main/examples/backtesting.ipynb create mode 100644 ferro-ta-main/examples/custom_indicator.py create mode 100644 ferro-ta-main/examples/quickstart.ipynb create mode 100644 ferro-ta-main/examples/streaming.ipynb create mode 100644 ferro-ta-main/fuzz/Cargo.toml create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_atr.rs create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_bbands.rs create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_ema.rs create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_macd.rs create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_mfi.rs create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_rsi.rs create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_sma.rs create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_stoch.rs create mode 100644 ferro-ta-main/fuzz/fuzz_targets/fuzz_wma.rs create mode 100644 ferro-ta-main/perf-contract/batch.json create mode 100644 ferro-ta-main/perf-contract/indicator_latency.json create mode 100644 ferro-ta-main/perf-contract/manifest.json create mode 100644 ferro-ta-main/perf-contract/runtime_hotspots.json create mode 100644 ferro-ta-main/perf-contract/simd.json create mode 100644 ferro-ta-main/perf-contract/streaming.json create mode 100644 ferro-ta-main/pyproject.toml create mode 100644 ferro-ta-main/python/ferro_ta/__init__.py create mode 100644 ferro-ta-main/python/ferro_ta/__init__.pyi create mode 100644 ferro-ta-main/python/ferro_ta/_binding.py create mode 100644 ferro-ta-main/python/ferro_ta/_indicator_manifest.yaml create mode 100644 ferro-ta-main/python/ferro_ta/_utils.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/__init__.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/adjust.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/attribution.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/backtest.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/cross_asset.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/crypto.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/derivatives_payoff.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/features.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/futures.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/live.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/multitf.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/optimize.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/options.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/options_strategy.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/plot.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/portfolio.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/regime.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/resample.py create mode 100644 ferro-ta-main/python/ferro_ta/analysis/signals.py create mode 100644 ferro-ta-main/python/ferro_ta/core/__init__.py create mode 100644 ferro-ta-main/python/ferro_ta/core/config.py create mode 100644 ferro-ta-main/python/ferro_ta/core/exceptions.py create mode 100644 ferro-ta-main/python/ferro_ta/core/logging_utils.py create mode 100644 ferro-ta-main/python/ferro_ta/core/raw.py create mode 100644 ferro-ta-main/python/ferro_ta/core/registry.py create mode 100644 ferro-ta-main/python/ferro_ta/data/__init__.py create mode 100644 ferro-ta-main/python/ferro_ta/data/adapters.py create mode 100644 ferro-ta-main/python/ferro_ta/data/aggregation.py create mode 100644 ferro-ta-main/python/ferro_ta/data/batch.py create mode 100644 ferro-ta-main/python/ferro_ta/data/chunked.py create mode 100644 ferro-ta-main/python/ferro_ta/data/resampling.py create mode 100644 ferro-ta-main/python/ferro_ta/data/streaming.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/__init__.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/cycle.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/extended.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/math_ops.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/momentum.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/overlap.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/pattern.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/price_transform.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/statistic.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/volatility.py create mode 100644 ferro-ta-main/python/ferro_ta/indicators/volume.py create mode 100644 ferro-ta-main/python/ferro_ta/logging_utils.py create mode 100644 ferro-ta-main/python/ferro_ta/mcp/__init__.py create mode 100644 ferro-ta-main/python/ferro_ta/mcp/__main__.py create mode 100644 ferro-ta-main/python/ferro_ta/py.typed create mode 100644 ferro-ta-main/python/ferro_ta/tools/__init__.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/alerts.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/api_info.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/dashboard.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/dsl.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/gpu.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/pipeline.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/tools.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/viz.py create mode 100644 ferro-ta-main/python/ferro_ta/tools/workflow.py create mode 100644 ferro-ta-main/python/ferro_ta/utils.py create mode 100644 ferro-ta-main/scripts/build_api_manifest.py create mode 100644 ferro-ta-main/scripts/bump_version.py create mode 100644 ferro-ta-main/scripts/check_api_manifest.py create mode 100644 ferro-ta-main/scripts/check_changelog.py create mode 100644 ferro-ta-main/scripts/pre_push_checks.sh create mode 100644 ferro-ta-main/src/aggregation/mod.rs create mode 100644 ferro-ta-main/src/alerts/mod.rs create mode 100644 ferro-ta-main/src/attribution/mod.rs create mode 100644 ferro-ta-main/src/backtest/commission.rs create mode 100644 ferro-ta-main/src/backtest/currency.rs create mode 100644 ferro-ta-main/src/backtest/mod.rs create mode 100644 ferro-ta-main/src/batch/mod.rs create mode 100644 ferro-ta-main/src/chunked/mod.rs create mode 100644 ferro-ta-main/src/crypto/mod.rs create mode 100644 ferro-ta-main/src/cycle/ht_dcperiod.rs create mode 100644 ferro-ta-main/src/cycle/ht_dcphase.rs create mode 100644 ferro-ta-main/src/cycle/ht_phasor.rs create mode 100644 ferro-ta-main/src/cycle/ht_sine.rs create mode 100644 ferro-ta-main/src/cycle/ht_trendline.rs create mode 100644 ferro-ta-main/src/cycle/ht_trendmode.rs create mode 100644 ferro-ta-main/src/cycle/mod.rs create mode 100644 ferro-ta-main/src/extended/mod.rs create mode 100644 ferro-ta-main/src/futures/basis.rs create mode 100644 ferro-ta-main/src/futures/curve.rs create mode 100644 ferro-ta-main/src/futures/mod.rs create mode 100644 ferro-ta-main/src/futures/roll.rs create mode 100644 ferro-ta-main/src/futures/synthetic.rs create mode 100644 ferro-ta-main/src/lib.rs create mode 100644 ferro-ta-main/src/math_ops/mod.rs create mode 100644 ferro-ta-main/src/momentum/adx.rs create mode 100644 ferro-ta-main/src/momentum/apo.rs create mode 100644 ferro-ta-main/src/momentum/aroon.rs create mode 100644 ferro-ta-main/src/momentum/bop.rs create mode 100644 ferro-ta-main/src/momentum/cci.rs create mode 100644 ferro-ta-main/src/momentum/cmo.rs create mode 100644 ferro-ta-main/src/momentum/mfi.rs create mode 100644 ferro-ta-main/src/momentum/mod.rs create mode 100644 ferro-ta-main/src/momentum/mom.rs create mode 100644 ferro-ta-main/src/momentum/ppo.rs create mode 100644 ferro-ta-main/src/momentum/roc.rs create mode 100644 ferro-ta-main/src/momentum/rsi.rs create mode 100644 ferro-ta-main/src/momentum/stoch.rs create mode 100644 ferro-ta-main/src/momentum/stochf.rs create mode 100644 ferro-ta-main/src/momentum/stochrsi.rs create mode 100644 ferro-ta-main/src/momentum/trix.rs create mode 100644 ferro-ta-main/src/momentum/ultosc.rs create mode 100644 ferro-ta-main/src/momentum/willr.rs create mode 100644 ferro-ta-main/src/options/american.rs create mode 100644 ferro-ta-main/src/options/chain.rs create mode 100644 ferro-ta-main/src/options/digital.rs create mode 100644 ferro-ta-main/src/options/greeks.rs create mode 100644 ferro-ta-main/src/options/iv.rs create mode 100644 ferro-ta-main/src/options/mod.rs create mode 100644 ferro-ta-main/src/options/payoff.rs create mode 100644 ferro-ta-main/src/options/pricing.rs create mode 100644 ferro-ta-main/src/options/realized_vol.rs create mode 100644 ferro-ta-main/src/options/surface.rs create mode 100644 ferro-ta-main/src/overlap/bbands.rs create mode 100644 ferro-ta-main/src/overlap/dema.rs create mode 100644 ferro-ta-main/src/overlap/ema.rs create mode 100644 ferro-ta-main/src/overlap/kama.rs create mode 100644 ferro-ta-main/src/overlap/ma_mavp.rs create mode 100644 ferro-ta-main/src/overlap/macd.rs create mode 100644 ferro-ta-main/src/overlap/macdext.rs create mode 100644 ferro-ta-main/src/overlap/mama.rs create mode 100644 ferro-ta-main/src/overlap/midpoint.rs create mode 100644 ferro-ta-main/src/overlap/midprice.rs create mode 100644 ferro-ta-main/src/overlap/mod.rs create mode 100644 ferro-ta-main/src/overlap/sar.rs create mode 100644 ferro-ta-main/src/overlap/sarext.rs create mode 100644 ferro-ta-main/src/overlap/sma.rs create mode 100644 ferro-ta-main/src/overlap/t3.rs create mode 100644 ferro-ta-main/src/overlap/tema.rs create mode 100644 ferro-ta-main/src/overlap/trima.rs create mode 100644 ferro-ta-main/src/overlap/wma.rs create mode 100644 ferro-ta-main/src/pattern/cdl2crows.rs create mode 100644 ferro-ta-main/src/pattern/cdl3blackcrows.rs create mode 100644 ferro-ta-main/src/pattern/cdl3inside.rs create mode 100644 ferro-ta-main/src/pattern/cdl3linestrike.rs create mode 100644 ferro-ta-main/src/pattern/cdl3outside.rs create mode 100644 ferro-ta-main/src/pattern/cdl3starsinsouth.rs create mode 100644 ferro-ta-main/src/pattern/cdl3whitesoldiers.rs create mode 100644 ferro-ta-main/src/pattern/cdlabandonedbaby.rs create mode 100644 ferro-ta-main/src/pattern/cdladvanceblock.rs create mode 100644 ferro-ta-main/src/pattern/cdlbelthold.rs create mode 100644 ferro-ta-main/src/pattern/cdlbreakaway.rs create mode 100644 ferro-ta-main/src/pattern/cdlclosingmarubozu.rs create mode 100644 ferro-ta-main/src/pattern/cdlconcealbabyswall.rs create mode 100644 ferro-ta-main/src/pattern/cdlcounterattack.rs create mode 100644 ferro-ta-main/src/pattern/cdldarkcloudcover.rs create mode 100644 ferro-ta-main/src/pattern/cdldoji.rs create mode 100644 ferro-ta-main/src/pattern/cdldojistar.rs create mode 100644 ferro-ta-main/src/pattern/cdldragonflydoji.rs create mode 100644 ferro-ta-main/src/pattern/cdlengulfing.rs create mode 100644 ferro-ta-main/src/pattern/cdleveningdojistar.rs create mode 100644 ferro-ta-main/src/pattern/cdleveningstar.rs create mode 100644 ferro-ta-main/src/pattern/cdlgapsidesidewhite.rs create mode 100644 ferro-ta-main/src/pattern/cdlgravestonedoji.rs create mode 100644 ferro-ta-main/src/pattern/cdlhammer.rs create mode 100644 ferro-ta-main/src/pattern/cdlhangingman.rs create mode 100644 ferro-ta-main/src/pattern/cdlharami.rs create mode 100644 ferro-ta-main/src/pattern/cdlharamicross.rs create mode 100644 ferro-ta-main/src/pattern/cdlhighwave.rs create mode 100644 ferro-ta-main/src/pattern/cdlhikkake.rs create mode 100644 ferro-ta-main/src/pattern/cdlhikkakemod.rs create mode 100644 ferro-ta-main/src/pattern/cdlhomingpigeon.rs create mode 100644 ferro-ta-main/src/pattern/cdlidentical3crows.rs create mode 100644 ferro-ta-main/src/pattern/cdlinneck.rs create mode 100644 ferro-ta-main/src/pattern/cdlinvertedhammer.rs create mode 100644 ferro-ta-main/src/pattern/cdlkicking.rs create mode 100644 ferro-ta-main/src/pattern/cdlkickingbylength.rs create mode 100644 ferro-ta-main/src/pattern/cdlladderbottom.rs create mode 100644 ferro-ta-main/src/pattern/cdllongleggeddoji.rs create mode 100644 ferro-ta-main/src/pattern/cdllongline.rs create mode 100644 ferro-ta-main/src/pattern/cdlmarubozu.rs create mode 100644 ferro-ta-main/src/pattern/cdlmatchinglow.rs create mode 100644 ferro-ta-main/src/pattern/cdlmathold.rs create mode 100644 ferro-ta-main/src/pattern/cdlmorningdojistar.rs create mode 100644 ferro-ta-main/src/pattern/cdlmorningstar.rs create mode 100644 ferro-ta-main/src/pattern/cdlonneck.rs create mode 100644 ferro-ta-main/src/pattern/cdlpiercing.rs create mode 100644 ferro-ta-main/src/pattern/cdlrickshawman.rs create mode 100644 ferro-ta-main/src/pattern/cdlrisefall3methods.rs create mode 100644 ferro-ta-main/src/pattern/cdlseparatinglines.rs create mode 100644 ferro-ta-main/src/pattern/cdlshootingstar.rs create mode 100644 ferro-ta-main/src/pattern/cdlshortline.rs create mode 100644 ferro-ta-main/src/pattern/cdlspinningtop.rs create mode 100644 ferro-ta-main/src/pattern/cdlstalledpattern.rs create mode 100644 ferro-ta-main/src/pattern/cdlsticksandwich.rs create mode 100644 ferro-ta-main/src/pattern/cdltakuri.rs create mode 100644 ferro-ta-main/src/pattern/cdltasukigap.rs create mode 100644 ferro-ta-main/src/pattern/cdlthrusting.rs create mode 100644 ferro-ta-main/src/pattern/cdltristar.rs create mode 100644 ferro-ta-main/src/pattern/cdlunique3river.rs create mode 100644 ferro-ta-main/src/pattern/cdlupsidegap2crows.rs create mode 100644 ferro-ta-main/src/pattern/cdlxsidegap3methods.rs create mode 100644 ferro-ta-main/src/pattern/common.rs create mode 100644 ferro-ta-main/src/pattern/mod.rs create mode 100644 ferro-ta-main/src/portfolio/mod.rs create mode 100644 ferro-ta-main/src/price_transform/avgprice.rs create mode 100644 ferro-ta-main/src/price_transform/medprice.rs create mode 100644 ferro-ta-main/src/price_transform/mod.rs create mode 100644 ferro-ta-main/src/price_transform/typprice.rs create mode 100644 ferro-ta-main/src/price_transform/wclprice.rs create mode 100644 ferro-ta-main/src/regime/mod.rs create mode 100644 ferro-ta-main/src/resampling/mod.rs create mode 100644 ferro-ta-main/src/signals/mod.rs create mode 100644 ferro-ta-main/src/statistic/beta.rs create mode 100644 ferro-ta-main/src/statistic/common.rs create mode 100644 ferro-ta-main/src/statistic/correl.rs create mode 100644 ferro-ta-main/src/statistic/dtw.rs create mode 100644 ferro-ta-main/src/statistic/linearreg.rs create mode 100644 ferro-ta-main/src/statistic/mod.rs create mode 100644 ferro-ta-main/src/statistic/stddev.rs create mode 100644 ferro-ta-main/src/statistic/var.rs create mode 100644 ferro-ta-main/src/streaming/mod.rs create mode 100644 ferro-ta-main/src/validation.rs create mode 100644 ferro-ta-main/src/volatility/atr.rs create mode 100644 ferro-ta-main/src/volatility/common.rs create mode 100644 ferro-ta-main/src/volatility/mod.rs create mode 100644 ferro-ta-main/src/volatility/natr.rs create mode 100644 ferro-ta-main/src/volatility/trange.rs create mode 100644 ferro-ta-main/src/volume/ad.rs create mode 100644 ferro-ta-main/src/volume/adosc.rs create mode 100644 ferro-ta-main/src/volume/mod.rs create mode 100644 ferro-ta-main/src/volume/obv.rs create mode 100644 ferro-ta-main/tests/conftest.py create mode 100644 ferro-ta-main/tests/fixtures/ohlcv_daily.csv create mode 100644 ferro-ta-main/tests/integration/conftest.py create mode 100644 ferro-ta-main/tests/integration/test_cross_surface_manifest.py create mode 100644 ferro-ta-main/tests/integration/test_integration.py create mode 100644 ferro-ta-main/tests/integration/test_streaming_accuracy.py create mode 100644 ferro-ta-main/tests/integration/test_vs_pandas_ta.py create mode 100644 ferro-ta-main/tests/integration/test_vs_ta.py create mode 100644 ferro-ta-main/tests/integration/test_vs_talib.py create mode 100644 ferro-ta-main/tests/integration/test_wasm_node_conformance.py create mode 100644 ferro-ta-main/tests/unit/analysis/__init__.py create mode 100644 ferro-ta-main/tests/unit/analysis/test_backtest_advanced.py create mode 100644 ferro-ta-main/tests/unit/analysis/test_backtest_v2.py create mode 100644 ferro-ta-main/tests/unit/conftest.py create mode 100644 ferro-ta-main/tests/unit/helpers.py create mode 100644 ferro-ta-main/tests/unit/indicators/__init__.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_cycle.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_extended.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_math_ops.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_momentum.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_overlap.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_pattern.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_price_transform.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_statistic.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_volatility.py create mode 100644 ferro-ta-main/tests/unit/indicators/test_volume.py create mode 100644 ferro-ta-main/tests/unit/streaming/__init__.py create mode 100644 ferro-ta-main/tests/unit/streaming/test_streaming.py create mode 100644 ferro-ta-main/tests/unit/test_coverage.py create mode 100644 ferro-ta-main/tests/unit/test_data_pipeline.py create mode 100644 ferro-ta-main/tests/unit/test_dataframe_integration.py create mode 100644 ferro-ta-main/tests/unit/test_derivatives.py create mode 100644 ferro-ta-main/tests/unit/test_derivatives_accuracy.py create mode 100644 ferro-ta-main/tests/unit/test_edge_cases.py create mode 100644 ferro-ta-main/tests/unit/test_ferro_ta.py create mode 100644 ferro-ta-main/tests/unit/test_infrastructure.py create mode 100644 ferro-ta-main/tests/unit/test_known_values.py create mode 100644 ferro-ta-main/tests/unit/test_math_ops_vs_numpy.py create mode 100644 ferro-ta-main/tests/unit/test_optional_dependency_wrappers.py create mode 100644 ferro-ta-main/tests/unit/test_property_based.py create mode 100644 ferro-ta-main/tests/unit/test_tools_and_api.py create mode 100644 ferro-ta-main/tests/unit/test_validation.py create mode 100644 ferro-ta-main/tests/unit/tools/__init__.py create mode 100644 ferro-ta-main/uv.lock create mode 100644 ferro-ta-main/wasm/Cargo.lock create mode 100644 ferro-ta-main/wasm/Cargo.toml create mode 100644 ferro-ta-main/wasm/README.md create mode 100644 ferro-ta-main/wasm/bench.js create mode 100644 ferro-ta-main/wasm/package.json create mode 100644 ferro-ta-main/wasm/src/lib.rs create mode 100644 my_indicators.py create mode 100644 pyproject.toml create mode 100644 python/raptorbt/__init__.py create mode 100644 python/raptorbt/__pycache__/__init__.cpython-312.pyc create mode 100644 python/raptorbt/_raptorbt.cp312-win_amd64.pyd create mode 100644 python/raptorbt/_raptorbt.cpython-311-darwin.so create mode 100644 requirements.txt create mode 100644 rustfmt.toml create mode 100644 src/core/error.rs create mode 100644 src/core/mod.rs create mode 100644 src/core/session.rs create mode 100644 src/core/timeseries.rs create mode 100644 src/core/types.rs create mode 100644 src/execution/fees.rs create mode 100644 src/execution/fill.rs create mode 100644 src/execution/mod.rs create mode 100644 src/execution/slippage.rs create mode 100644 src/indicators/ferro_bridge.rs create mode 100644 src/indicators/mod.rs create mode 100644 src/indicators/momentum.rs create mode 100644 src/indicators/rolling.rs create mode 100644 src/indicators/strength.rs create mode 100644 src/indicators/tick_features.rs create mode 100644 src/indicators/trend.rs create mode 100644 src/indicators/volatility.rs create mode 100644 src/indicators/volume.rs create mode 100644 src/lib.rs create mode 100644 src/metrics/drawdown.rs create mode 100644 src/metrics/mod.rs create mode 100644 src/metrics/streaming.rs create mode 100644 src/metrics/trade_stats.rs create mode 100644 src/portfolio/allocation.rs create mode 100644 src/portfolio/engine.rs create mode 100644 src/portfolio/mod.rs create mode 100644 src/portfolio/monte_carlo.rs create mode 100644 src/portfolio/position.rs create mode 100644 src/python/bindings.rs create mode 100644 src/python/mod.rs create mode 100644 src/python/numpy_bridge.rs create mode 100644 src/signals/expression.rs create mode 100644 src/signals/mod.rs create mode 100644 src/signals/processor.rs create mode 100644 src/signals/synchronizer.rs create mode 100644 src/signals/tick_signals.rs create mode 100644 src/stops/atr.rs create mode 100644 src/stops/fixed.rs create mode 100644 src/stops/mod.rs create mode 100644 src/stops/trailing.rs create mode 100644 src/strategies/basket.rs create mode 100644 src/strategies/mod.rs create mode 100644 src/strategies/multi.rs create mode 100644 src/strategies/options.rs create mode 100644 src/strategies/pairs.rs create mode 100644 src/strategies/single.rs create mode 100644 src/strategies/spreads.rs create mode 100644 src/strategies/tick.rs create mode 100644 test_with_mt5.py create mode 100644 tests/test_indicators.rs create mode 100644 tests/test_portfolio.rs create mode 100644 uv.lock create mode 100644 指标扩展.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e23c5f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Python +.venv \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..36e7b06 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,833 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "ferro_ta_core" +version = "1.2.0" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ndarray" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef41cbb417ea83b30525259e30ccef6af39b31c240bda578889494c5392d331" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53bdbb96d49157e65d45cc287af5f32ffadd5f4761438b527b055fb0d4bb8233" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deaa5745de3f5231ce10517a1f5dd97d53e5a2fd77aa6b5842292085831d48d7" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b42531d03e08d4ef1f6e85a2ed422eb678b8cd62b762e53891c05faf0d4afa" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7305c720fa01b8055ec95e484a6eca7a83c841267f0dd5280f0c8b8551d2c158" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7e9b68bb9c3149c5b0cade5d07f953d6d125eb4337723c4ccdb665f1f96185" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "raptorbt" +version = "0.4.1" +dependencies = [ + "approx", + "criterion", + "ferro_ta_core", + "numpy", + "pyo3", + "rayon", + "serde", + "thiserror", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..51892de --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "raptorbt" +version = "0.4.1" +edition = "2021" +description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint." +authors = ["Alphabench "] +license = "MIT" +repository = "https://github.com/alphabench/raptorbt" +homepage = "https://www.alphabench.in/raptorbt" +readme = "README.md" +keywords = ["backtesting", "trading", "quantitative-finance", "rust", "python"] +categories = ["finance", "simulation"] + +[lib] +name = "raptorbt" +crate-type = ["cdylib", "rlib"] + +[dependencies] +pyo3 = { version = "0.20", features = ["extension-module"] } +numpy = "0.20" +rayon = "1.8" +thiserror = "1.0" +serde = { version = "1.0", features = ["derive"] } +ferro_ta_core = { path = "./ferro-ta-main/crates/ferro_ta_core", default-features = false } + +[dev-dependencies] +criterion = "0.5" +approx = "0.5" + +[[bench]] +name = "backtest_benchmark" +harness = false + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 3 +strip = true \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f9e6c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Alphabench + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Mt5Bridge使用指南.md b/Mt5Bridge使用指南.md new file mode 100644 index 0000000..2c70fd4 --- /dev/null +++ b/Mt5Bridge使用指南.md @@ -0,0 +1,1101 @@ +# Mt5Bridge API 使用指南 + +> 本文档面向**开发者**,假设 Bridge 已在云端部署运行。直接复制代码即可使用。 + +--- + +## 连接信息 + +| 项目 | 值 | +|------|-----| +| 地址 | `http://61.164.252.86:13485` | +| 认证 | `X-API-Key` Header 或 `?key=` URL 参数 | +| 格式 | 所有返回均为 JSON | +| WebSocket | `ws://61.164.252.86:13485`,握手时用 Header `X-API-Key` | +| SSE | `http://.../stream/ticks-sse/{symbol}`,Header 或 `?key=` 均可 | + +> **浏览器注意**:原生 `WebSocket` 不支持自定义 Header,须用 `?key=` URL 参数;SSE 用 `new EventSource(url + '?key=...')` 同理。Node/Python/Java 客户端用 Header 更干净。 + +--- + +## 快速开始(Python) + +```python +import requests + +BRIDGE = "http://61.164.252.86:13485" +KEY = "your-api-key" + +def api(path, params=None): + """统一请求封装""" + resp = requests.get(f"{BRIDGE}{path}", params=params, headers={"X-API-Key": KEY}) + resp.raise_for_status() + return resp.json() + +def api_post(path, data): + """POST 请求封装""" + resp = requests.post(f"{BRIDGE}{path}", json=data, headers={"X-API-Key": KEY}) + resp.raise_for_status() + return resp.json() + +# 测试连接 +print(api("/health")) +``` + +--- + +## API 接口速查 + +| 分类 | 方法 | 端点 | 用途 | +|------|------|------|------| +| **系统** | `GET` | `/health` | 健康检查 + MT5 连接状态 | +| **账户** | `GET` | `/account` | 余额/净值/保证金/杠杆 | +| **行情** | `GET` | `/symbols/{symbol}` | 品种信息(点值/手数/合约) | +| | `GET` | `/symbols/{symbol}/tick` | 拉取一次 tick | +| | `WS` | `/stream/ticks/{symbol}` | **实时 tick 推送**(推荐) | +| | `GET` | `/stream/ticks-sse/{symbol}` | SSE 推送(浏览器/内网代理友好) | +| **K 线** | `GET` | `/rates/from-pos` | K 线(按偏移量) | +| | `GET` | `/rates/from-date` | **K 线(按时间范围)⭐** | +| **持仓** | `GET` | `/positions[?symbol=]` | 当前持仓列表 | +| | `POST` | `/position/close` | 平仓(全/部分) | +| | `POST` | `/position/modify` | 改 SL/TP | +| | `POST` | `/position/close-by` | 对冲平仓(节省点差) | +| | `POST` | `/positions/close-batch` | 批量平仓(按 magic/symbol) | +| **挂单** | `GET` | `/orders[?symbol=]` | 挂单列表 | +| | `POST` | `/order/cancel` | 撤单 | +| | `POST` | `/order/modify` | 改挂单价/SL/TP | +| **下单** | `POST` | `/order/check` | 预检(不成交) | +| | `POST` | `/order/send` | 实际下单 | +| **历史** | `GET` | `/history/deals` | 历史成交 | +| **信号** | `GET/POST/DELETE` | `/gvar[/{name}]` | MQL5 全局变量(指标信号桥) | + +> 推送类端点(WS/SSE):每品种最多 50 个订阅者,超出返回 `429`;每 30 秒发一条心跳包用于穿透 NAT 保持连接。 + +--- + +## ⚠️ 隐含约定与已知陷阱(先读) + +下面这些坑都是踩过的,**不读这节直接调接口几乎必踩**: + +### P1. `/history/deals` 的 `date_to` 是 **EXCLUSIVE**(不含当天) + +```bash +# ❌ 0 deals — 07-08 当天全部丢失 +GET /history/deals?date_from=2026-07-06&date_to=2026-07-08 + +# ✅ 42 deals — date_to 设成"明天"才能取到 07-08 当天 +GET /history/deals?date_from=2026-07-08&date_to=2026-07-09 +``` + +**规则**:永远把 `date_to` 设为"目标日期的下一天"。 + +### P2. `/history/deals` 的 `entry` 字段语义跟 MT5 标准 **相反** + +``` +bridge entry = 1 ⇒ OUT(关仓)—— profit 字段是已实现 P&L(USD) +bridge entry = 0 ⇒ IN (开仓)—— profit 固定为 0 +``` + +MT5 MQL5 原生约定是 `DEAL_ENTRY_IN=0 / DEAL_ENTRY_OUT=1`,这个 bridge 的 C# 实现把语义反过来了。 +**如果不验证就用 close 路径过滤 deal,会一个都匹配不到**(结果 P&L 永远是 0)。 + +```python +# ✅ 正确:找关仓 deal +exits = [d for d in deals if d.get('entry') == 1 and d.get('magic') == 88001] +``` + +### P3. `/order/send` 只返回 `{retcode, order, comment}`,**没有成交价、没有 deal ticket** + +```json +// 实际响应(成功) +{"data": {"retcode": 10009, "order": 1797395084, "comment": "Request executed"}} +``` + +bridge 不返回 `price` 也不返回 `deal` 字段。**所以拿真实 fill 价格和实现 P&L,唯一办法是 close 之后查 `/history/deals`**。 + +```python +# ❌ 永远拿不到正确价格 +result = api_post('/order/send', {...})['data'] +result.get('price') # None + +# ✅ 正确:成交后从 history/deals 拿 +deals = api('/history/deals', params={'date_from': today, 'date_to': tomorrow})['data'] +exit_deal = next(d for d in deals if d['entry'] == 1 and d['symbol'] == sym) +realized_pnl_usd = exit_deal['profit'] # broker 已经换算成 deposit currency +actual_fill_price = exit_deal['price'] +``` + +### P4. `/positions.profit` **是 deposit currency(USD),不是 quote currency** + +```python +# USDCAD SELL 当前浮动 -0.17 +# 这是 USD 真实值 (-0.24 CAD ÷ 1.417 USD/CAD = -0.169 USD ≈ -0.17) +# 不是 CAD! +``` + +MT5 `POSITION_PROFIT` 的官方语义就是 in deposit currency,bridge 严格遵循。 +**如果手动算 USDCAD / USDJPY P&L 时按 quote currency 处理,会差一个汇率倍数**(USDCAD 大约 1.4×)。 + +### P5. bridge 拿到的 tick 跟 broker 实际 fill 差 1-4 ticks + +`/positions` 的 `price_open` / `price_current` 和 `/order/send` 时看到的 tick 跟 broker 服务器**真实成交价**有几毫秒级的时间差,导致值差 0.0001-0.0004(约 0.1-0.4 pip)。 + +``` +broker 实际 fill: 1.41715 +/positions.price_open: 1.41711 ← 差 0.00004 (0.4 pip) +``` + +**永远以 `/history/deals` 里的 `price` 为准做对账,不要用 `/positions` 的 price_open**。 + +### P6. `/position/close` 与 `/order/send` 的成功 retcode 含义不同 + +| 端点 | 成功 retcode | 失败 retcode | 来源 | +|------|--------------|--------------|------| +| `/order/send` | MT5 原生(通常 `10009`) | MT5 原生 | 直接透传 `result.Retcode` | +| `/position/close` | **合成** `10009` | **合成** `10004` | C# 代码 `ok ? 10009u : 10004u` | + +两者都是 `10009 = success`,但**别假设 0 是 success**。建议统一判 `retcode == 10009`。 + +--- + +## 1. 健康检查 + +``` +GET /health +``` + +```python +status = api("/health") +# {"status": "healthy", "mt5_connected": true, "api_version": "1.0.0"} +``` + +--- + +## 2. 账户信息 + +``` +GET /account +``` + +```python +acc = api("/account")["data"][0] +print(f"余额: {acc['balance']}, 净值: {acc['equity']}, 浮动盈亏: {acc['profit']}") +print(f"保证金: {acc['margin']}, 可用保证金: {acc['margin_free']}, 比例: {acc['margin_level']}%") +print(f"杠杆: 1:{acc['leverage']}, 币种: {acc['currency']}") +``` + +**返回字段:** + +| 字段 | 含义 | +|------|------| +| login | 账户号 | +| balance | 余额 | +| equity | 净值 | +| profit | 浮动盈亏 | +| margin | 已用保证金 | +| margin_free | 可用保证金 | +| margin_level | 保证金比例 | +| leverage | 杠杆 | +| currency | 账户币种 | +| trade_allowed | 是否允许交易 | +| trade_expert | 是否允许 EA 交易 | + +--- + +## 3. 实时行情 + +#### 3-1. 主动拉取(一次性) + +``` +GET /symbols/{symbol}/tick +``` + +自动将品种加入 MT5 Market Watch,并等待最多 3 秒获取真实 tick 数据(解决品种未订阅时返回空值的问题)。 + +```python +def get_tick(symbol): + data = api(f"/symbols/{symbol}/tick")["data"][0] + return data["bid"], data["ask"] + +bid, ask = get_tick("XAUUSD") +print(f"XAUUSD Bid: {bid} Ask: {ask} Spread: {ask - bid}") +``` + +**返回字段:** + +| 字段 | 含义 | +|------|------| +| bid | 卖价 | +| ask | 买价 | +| last | 最新成交价 | +| volume | 成交量 | +| time | 时间 | + +#### 3-2. 订阅推送(WebSocket 流式)⭐ 推荐 + +``` +WS /stream/ticks/{symbol} +``` + +MT5 每收到一个 tick 就立即推给所有订阅者,省去轮询。 + +- 走 `X-API-Key` 认证(Header `X-API-Key: your-key`,WebSocket 客户端在握手 Header 里带) +- 连上时自动把品种加入 MT5 Market Watch;最后一个订阅者断开时自动移除 +- 每个品种最多 50 个订阅者(含 WS + SSE 总数),超出返回 `429` +- 每 30 秒发一条心跳(无 tick 时也发),客户端可用于保活与断线检测 + +**推送格式(每条 tick 一帧 JSON 文本):** + +```json +{ + "type": "tick", + "symbol": "XAUUSDc", + "time": "2026-07-08T10:30:45", + "bid": 4180.0, + "ask": 4180.5, + "last": 4180.2, + "volume": 100, + "time_msc": "2026-07-08T10:30:45.123000", + "flags": 6 +} +``` + +**心跳包:** + +```json +{"type":"heartbeat","time":"2026-07-08T10:31:15"} +``` + +#### 3-3. SSE 推送(不能用 WebSocket 的环境) + +``` +GET /stream/ticks-sse/{symbol} → Content-Type: text/event-stream +``` + +面向无法建 WebSocket 的客户端(部分老浏览器、内网代理、curl 测试等)。语义同 3-2,每条 tick 一帧 SSE: + +``` +data: {"type":"tick","symbol":"XAUUSDc","bid":4180.0,...} + +data: {"type":"tick","symbol":"XAUUSDc","bid":4181.0,...} + +``` + +**curl 测试:** + +```bash +curl -N -H "X-API-Key: your-api-key" \ + http://61.164.252.86:13485/stream/ticks-sse/XAUUSDc +``` + +**Python SSE 客户端(`sseclient-py`):** + +```python +from sseclient import SSEClient +import json + +messages = SSEClient("http://61.164.252.86:13485/stream/ticks-sse/XAUUSDc", + headers={"X-API-Key": "your-api-key"}) +for msg in messages: + data = json.loads(msg.data) + if data.get("type") == "heartbeat": + continue + print(data["symbol"], data["bid"], data["ask"]) +``` + +**Python 示例(需安装 `websocket-client`):** + +```python +import websocket +import threading + +def on_message(ws, msg): + tick = eval(msg) # 或 json.loads(msg) + print(f"{tick['symbol']} Bid:{tick['bid']} Ask:{tick['ask']}") + +def on_open(ws): + print("connected") + +def on_close(ws, code, reason): + print(f"disconnected: {code} {reason}") + +ws = websocket.WebSocketApp( + f"ws://61.164.252.86:13485/stream/ticks/XAUUSDc", + header=[f"X-API-Key: your-api-key"], + on_message=on_message, + on_open=on_open, + on_close=on_close, +) +ws.run_forever() +``` + +**`websockets` 库(asyncio 版):** + +```python +import asyncio +import websockets +import json + +async def watch_ticks(): + headers = {"X-API-Key": "your-api-key"} + async with websockets.connect( + "ws://61.164.252.86:13485/stream/ticks/XAUUSDc", + additional_headers=headers, + ) as ws: + async for raw in ws: + tick = json.loads(raw) + print(tick["symbol"], tick["bid"], tick["ask"]) + +asyncio.run(watch_ticks()) +``` + +**浏览器控制台测试:** + +```js +const ws = new WebSocket("ws://61.164.252.86:13485/stream/ticks/XAUUSDc", { + headers: { "X-API-Key": "your-api-key" } // 浏览器原生 WS 不支持自定义 Header,需走 ?key= 参数,见下 +}); +// 浏览器场景:用 query 参数传 key +const ws2 = new WebSocket("ws://61.164.252.86:13485/stream/ticks/XAUUSDc?key=your-api-key"); +ws2.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +--- + +## 4. 品种信息 + +``` +GET /symbols/{symbol} +``` + +bid/ask 从实时 tick 数据获取(自动等待最多 3 秒),避免品种刚加入 Market Watch 时返回 0 的问题。 + +```python +def get_symbol_info(symbol): + info = api(f"/symbols/{symbol}")["data"][0] + print(f"品种: {info['name']}, 描述: {info['description']}") + print(f"小数位: {info['digits']}, 点值: {info['point']}") + print(f"最小手数: {info['volume_min']}, 最大: {info['volume_max']}, 步长: {info['volume_step']}") + print(f"合约大小: {info['trade_contract_size']}") + return info +``` + +--- + +## 5. 历史 K 线(按偏移量) + +``` +GET /rates/from-pos?symbol={symbol}&timeframe={timeframe}&start_pos={start}&count={count} +``` + +| 参数 | 可选值 | +|------|--------| +| timeframe | `TIMEFRAME_M1` / `M5` / `M15` / `M30` / `H1` / `H4` / `D1` | +| start_pos | 0 = 最新,1 = 前一根,以此类推 | +| count | 获取数量,最大 10000 | + +```python +import pandas as pd + +def get_rates(symbol, timeframe, count): + """获取 K 线并转为 DataFrame""" + data = api("/rates/from-pos", params={ + "symbol": symbol, + "timeframe": f"TIMEFRAME_{timeframe}", + "start_pos": 0, + "count": count + })["data"] + df = pd.DataFrame(data) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + +# 获取最近 100 根 H1 K 线 +df = get_rates("XAUUSD", "H1", 100) +print(df.head()) +``` + +**返回字段:** `time`, `open`, `high`, `low`, `close`, `tick_volume`, `spread`, `real_volume` + +--- + +### 5-2. 历史 K 线(按时间范围)⭐ 推荐 + +``` +GET /rates/from-date?symbol={symbol}&timeframe={timeframe}&date_from={date_from}&date_to={date_to} +``` + +| 参数 | 可选值 | +|------|--------| +| timeframe | `TIMEFRAME_M1` / `M5` / `M15` / `M30` / `H1` / `H4` / `D1` | +| date_from | 起始日期,ISO-8601 或 `yyyy-MM-dd` | +| date_to | 结束日期,ISO-8601 或 `yyyy-MM-dd` | + +```python +def get_rates_by_date(symbol, timeframe, date_from, date_to): + """按时间范围获取 K 线""" + data = api("/rates/from-date", params={ + "symbol": symbol, + "timeframe": f"TIMEFRAME_{timeframe}", + "date_from": date_from, + "date_to": date_to, + })["data"] + df = pd.DataFrame(data) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + +# 获取 2026年7月1日 ~ 7月3日 的 H1 K 线 +df = get_rates_by_date("XAUUSD", "H1", "2026-07-01", "2026-07-03") +print(df.head()) +``` + +**返回字段:** 同 `/rates/from-pos` + +--- + +## 6. 当前持仓 + +``` +GET /positions[?symbol={symbol}] +``` + +symbol 可选过滤。 + +```python +def get_positions(symbol=None): + return api("/positions", params={"symbol": symbol} if symbol else None)["data"] + +positions = get_positions() +for pos in positions: + print(f"{pos['ticket']} {pos['symbol']} " + f"{'买' if pos['type'] == 0 else '卖'} " + f"手数:{pos['volume']} 盈亏:{pos['profit']}") +``` + +**返回字段:** `ticket`, `symbol`, `type`(0=买,1=卖), `volume`, `price_open`, `sl`, `tp`, `price_current`, `swap`, `profit`, `comment`, `magic` + +### 6-1. 平仓 + +``` +POST /position/close +``` + +```json +// 全平 +{ "ticket": 12345678 } +// 部分平仓 +{ "ticket": 12345678, "volume": 0.05 } +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| ticket | ✅ | 持仓编号 | +| volume | ❌ | 平仓手数;不传/0 = 全平;>0 = 部分平仓 | +| deviation | ❌ | 允许滑点(默认 10) | + +```python +def close_position(ticket, volume=None): + body = {"ticket": ticket} + if volume: body["volume"] = volume + return api_post("/position/close", body)["data"] + +close_position(12345678) # 全平 +close_position(12345678, volume=0.05) # 部分平 +``` + +### 6-2. 改持仓 SL/TP + +``` +POST /position/modify +``` + +```json +{ "ticket": 12345678, "sl": 4170.0, "tp": 4190.0 } +``` + +SL/TP 设为 0 表示清除对应止损/止盈。 + +```python +def modify_position(ticket, sl=0, tp=0): + return api_post("/position/modify", {"ticket": ticket, "sl": sl, "tp": tp})["data"] +``` + +### 6-3. 对冲平仓(节省点差) + +``` +POST /position/close-by +``` + +用一张反向持仓对冲平仓,只收一次点差(MT5 净额结算),适合双向网格 / 锁仓策略快速离场。 + +```json +{ "position": 111, "position_by": 222 } +``` + +要求:两张持仓 **同品种 + 反向**。 + +```python +def close_by(ticket_a, ticket_b): + return api_post("/position/close-by", {"position": ticket_a, "position_by": ticket_b})["data"] +``` + +### 6-4. 移动止损(客户端轮询模式) + +Bridge 不在服务端跑轮询,暴露 `/position/modify` 由客户端自己做: + +```python +def trail_stop(ticket, distance, step): + """distance: 跟踪距离(如 50 点);step: 最小推进步长(如 10 点)""" + pos = next((p for p in bridge.positions() if p["ticket"] == ticket), None) + if not pos: + return + current = pos["price_current"] + if pos["type"] == 0: # 多单 + new_sl = current - distance + if new_sl - pos["sl"] >= step: + bridge.modify_position(ticket, sl=new_sl) + else: # 空单 + new_sl = current + distance + if pos["sl"] - new_sl >= step: + bridge.modify_position(ticket, sl=new_sl) + +# 每秒跑一次 +while True: + for p in bridge.positions(): + trail_stop(p["ticket"], distance=50, step=10) + time.sleep(1) +``` + +也可以用 WebSocket tick 流推送驱动,把 `time.sleep(1)` 换成 tick 回调,反应更快。 + +### 6-5. 批量平仓 + +``` +POST /positions/close-batch +``` + +按 `symbol` 和/或 `magic` 批量平仓。**至少传一个**过滤条件,避免误清整个账户。 + +```json +{ "magic": 123456 } +{ "symbol": "XAUUSDc", "magic": 123456 } +{ "symbol": "XAUUSDc" } +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| symbol | 一 | 品种过滤 | +| magic | 一 | Magic Number 过滤 | +| deviation | ❌ | 允许滑点(默认 10) | + +```python +def close_by_magic(magic): + return api_post("/positions/close-batch", {"magic": magic})["data"] + +result = close_by_magic(123456) +print(f"已平 {result['closed']} 单,失败 {result['failed']} 单") +# data 数组里有每张单的 ticket / symbol / retcode / comment +``` + +--- + +## 7. 挂单 + +``` +GET /orders?symbol={symbol} +``` + +symbol 可选,不传返回全部。 + +```python +orders = api("/orders")["data"] +for o in orders: + print(f"{o['ticket']} {o['symbol']} 类型:{o['type']} 手数:{o['volume_initial']}") +``` + +### 7-1. 撤单 + +``` +POST /order/cancel +``` + +```json +{ "ticket": 87654321 } +``` + +```python +def cancel_order(ticket): + return api_post("/order/cancel", {"ticket": ticket})["data"] + +cancel_order(87654321) +``` + +### 7-2. 改挂单 + +``` +POST /order/modify +``` + +```json +{ "ticket": 87654321, "price": 4180.0, "sl": 4170.0, "tp": 4190.0 } +``` + +sl/tp 设为 0 表示清除。 + +```python +def modify_order(ticket, price, sl=0, tp=0): + return api_post("/order/modify", {"ticket": ticket, "price": price, "sl": sl, "tp": tp})["data"] +``` + +--- + +## 8. 订单预检 + +``` +POST /order/check +``` + +下单前验证,不会真正执行。检查保证金是否足够、价格是否有效等。 + +**填充模式自动适配**:服务端会根据品种的 `SYMBOL_FILLING_MODE` 自动选择经纪商支持的填充模式。如果请求的 `type_filling` 不被支持,会按 IOC(1) → FOK(0) → RETURN(2) 顺序降级,无需客户端手动判断。 + +```python +def check_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, comment=""): + """预检订单""" + data = { + "action": 1, # 1=即时成交 + "symbol": symbol, + "volume": volume, + "order_type": order_type, # 0=市价买, 1=市价卖 + "price": price, + "sl": sl or 0, + "tp": tp or 0, + "magic": magic, + "comment": comment, + "deviation": 10, + "type_filling": 0 # 0=FOK, 1=IOC, 2=RETURN — 服务端自动适配,不传也行 + } + result = api_post("/order/check", data)["data"] + print(f"预检结果: retcode={result['retcode']}, comment={result['comment']}") + if result['retcode'] == 0: + print("✅ 可以下单") + else: + print("❌ 不可下单") + return result + +check_order("XAUUSD", 0.01, 0, 4180.0, sl=4170.0, tp=4190.0) +``` + +**type_filling 说明:** + +| 值 | 含义 | 说明 | +|------|------|------| +| 0 | FOK (Fill or Kill) | 必须全部成交,否则取消 | +| 1 | IOC (Immediate or Cancel) | 能成交多少成交多少 | +| 2 | RETURN | 剩余部分留在订单簿 | + +> 不同经纪商支持的填充模式不同(如 ICMarkets 只支持 IOC),服务端会自动降级,客户端无需关心。 + +--- + +## 9. 下单 + +``` +POST /order/send +``` + +与 `/order/check` 相同,`type_filling` 会自动适配经纪商支持的填充模式。 + +```python +def send_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, comment=""): + """下单""" + data = { + "request": { + "action": 1, + "symbol": symbol, + "volume": volume, + "order_type": order_type, + "price": price, + "sl": sl or 0, + "tp": tp or 0, + "magic": magic, + "comment": comment, + "deviation": 10, + "type_filling": 0 # 自动适配,不传也行 + } + } + result = api_post("/order/send", data)["data"] + print(f"下单结果: retcode={result['retcode']}, order={result['order']}, comment={result['comment']}") + return result + +# 市价买入 0.01 手 XAUUSD +result = send_order("XAUUSD", 0.01, 0, 4180.0, sl=4170.0, tp=4190.0) +``` + +**order_type 说明:** + +| 值 | 含义 | +|------|------| +| 0 | 市价买入 | +| 1 | 市价卖出 | +| 2 | 限价买入 | +| 3 | 限价卖出 | +| 4 | 止损买入 | +| 5 | 止损卖出 | + +--- + +## 10. 历史成交 + +``` +GET /history/deals?date_from={from}&date_to={to}&symbol={symbol} +``` + +```python +deals = api("/history/deals", params={ + "date_from": "2026-07-01", + "date_to": "2026-07-03", + "symbol": "XAUUSD" +})["data"] + +for d in deals: + print(f"{d['ticket']} {d['time']} {d['symbol']} " + f"手数:{d['volume']} 价格:{d['price']} 盈亏:{d['profit']}") +``` + +--- + +## 11. 全局变量(MQL5 信号桥) + +> 让 MQL5 指标/EA 把信号写出来,Python 通过 Bridge 读取。不用翻译 MQL5 代码。 + +说明: + +- 这部分属于“MT5 指标/EA 信号导出”能力,不影响 Bridge 的账户、行情、历史、持仓、挂单、预检、下单等基础 API。 +- 如果你只更新了远程 `Mt5Bridge.dll`,没有替换 `Alpha Trend.ex5`,Bridge 仍然可以正常工作,`/gvar` 也仍会返回旧版指标写出的变量名。 +- 只有在你需要“已收盘 K 线信号”和“带周期/参数作用域的新变量名”时,才需要重新编译并替换新版 `Alpha Trend.ex5`。 + +#### 列出所有全局变量 + +``` +GET /gvar +``` + +```python +gvars = api("/gvar") +print(gvars) +# {"data": [{"name": "AT_Trend_XAUUSD", "value": 1}, ...], "count": 5} +``` + +#### 读取指定变量 + +``` +GET /gvar/{name} +``` + +```python +trend = api("/gvar/AT_Trend_XAUUSD")["value"] +print(f"趋势方向: {'多头' if trend == 1 else '空头'}") +``` + +#### 写入变量 + +``` +POST /gvar/{name} +``` + +Body: `{"value": 75.5}` + +```python +def set_gvar(name, value): + return api_post(f"/gvar/{name}", {"value": value}) + +set_gvar("MY_RSI", 75.5) +``` + +#### 删除变量 + +``` +DELETE /gvar/{name} +``` + +--- + +## MQL5 指标 → Python 完整流程 + +### 第一步:改指标源码,输出已收盘 K 线信号 + +这一步是“升级指标导出行为”的可选步骤,不是 Bridge 基础 API 的必需步骤。 + +```mql5 +// 在 OnCalculate 末尾加 +int last_closed = rates_total - 2; +string key = StringFormat("MY_SIGNAL_%s_%s", _Symbol, EnumToString(_Period)); +GlobalVariableSet(key, signal_value[last_closed]); +``` + +### 第二步:Python 读取信号 + +```python +def read_signal(): + try: + return api(f"/gvar/MY_SIGNAL_XAUUSD_PERIOD_H1")["value"] + except: + return None + +signal = read_signal() +print(f"指标信号: {signal}") +``` + +如果你仍在使用旧版 `Alpha Trend.ex5`,则读取方式应继续对应旧键名,例如: + +```python +trend = api("/gvar/AT_Trend_XAUUSD")["value"] +buy_signal = api("/gvar/AT_Buy_XAUUSD")["value"] +``` + +### 第三步:根据信号做决策 + +```python +def on_tick(): + signal = read_signal() + if signal != 1: + return # 没信号,不动 + + if not bridge.has_position("XAUUSD"): + bid, ask = bridge.tick("XAUUSD") + bridge.buy("XAUUSD", 0.01, ask, sl=ask - 50, tp=ask + 100) + print("指标发出买入信号,已开多") +``` + +## 完整策略模板 + +```python +import requests +import pandas as pd +import time +from datetime import datetime + +BRIDGE = "http://61.164.252.86:13485" +KEY = "your-api-key" + +class Mt5Bridge: + def __init__(self): + self.headers = {"X-API-Key": KEY} + + def _get(self, path, params=None): + r = requests.get(f"{BRIDGE}{path}", params=params, headers=self.headers) + r.raise_for_status() + return r.json() + + def _post(self, path, data): + r = requests.post(f"{BRIDGE}{path}", json=data, headers=self.headers) + r.raise_for_status() + return r.json() + + def stream_ticks(self, symbol, on_tick): + """订阅 tick 推送,on_tick 回调收到 dict,阻塞运行。需 pip install websocket-client""" + import websocket + url = f"{BRIDGE.replace('http', 'ws', 1)}/stream/ticks/{symbol}" + ws = websocket.WebSocketApp( + url, + header=[f"X-API-Key: {KEY}"], + on_message=lambda ws, msg: on_tick(eval(msg)), + on_error=lambda ws, err: print(f"ws error: {err}"), + ) + ws.run_forever() + + def stream_ticks_async(self, symbol, on_tick): + """后台线程订阅 tick,不阻塞主线程""" + import threading + t = threading.Thread(target=self.stream_ticks, args=(symbol, on_tick), daemon=True) + t.start() + return t + + # ── 行情 ── + def tick(self, symbol): + d = self._get(f"/symbols/{symbol}/tick")["data"][0] + return d["bid"], d["ask"] + + def rates(self, symbol, timeframe, count): + return self._get("/rates/from-pos", params={ + "symbol": symbol, "timeframe": f"TIMEFRAME_{timeframe}", + "start_pos": 0, "count": count + })["data"] + + def to_df(self, symbol, timeframe, count): + df = pd.DataFrame(self.rates(symbol, timeframe, count)) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + + # ── 账户 ── + def account(self): + return self._get("/account")["data"][0] + + # ── 持仓 ── + def positions(self, symbol=None): + return self._get("/positions", params={"symbol": symbol} if symbol else None)["data"] + + def has_position(self, symbol): + return any(p["symbol"] == symbol for p in self.positions()) + + def close(self, ticket, volume=None): + body = {"ticket": ticket} + if volume: body["volume"] = volume + return self._post("/position/close", body)["data"] + + def modify_position(self, ticket, sl=0, tp=0): + return self._post("/position/modify", {"ticket": ticket, "sl": sl, "tp": tp})["data"] + + def close_by(self, ticket_a, ticket_b): + return self._post("/position/close-by", {"position": ticket_a, "position_by": ticket_b})["data"] + + def close_batch(self, magic=None, symbol=None, deviation=None): + body = {k: v for k, v in {"magic": magic, "symbol": symbol, "deviation": deviation}.items() if v is not None} + return self._post("/positions/close-batch", body) + + # ── 挂单 ── + def orders(self, symbol=None): + return self._get("/orders", params={"symbol": symbol} if symbol else None)["data"] + + def cancel_order(self, ticket): + return self._post("/order/cancel", {"ticket": ticket})["data"] + + def modify_order(self, ticket, price, sl=0, tp=0): + return self._post("/order/modify", {"ticket": ticket, "price": price, "sl": sl, "tp": tp})["data"] + + # ── 下单 ── + def buy(self, symbol, volume, price, sl=0, tp=0, magic=0, comment=""): + return self._send(symbol, volume, 0, price, sl, tp, magic, comment) + + def sell(self, symbol, volume, price, sl=0, tp=0, magic=0, comment=""): + return self._send(symbol, volume, 1, price, sl, tp, magic, comment) + + def _send(self, symbol, volume, order_type, price, sl, tp, magic, comment): + return self._post("/order/send", { + "request": { + "action": 1, "symbol": symbol, "volume": volume, + "order_type": order_type, "price": price, + "sl": sl, "tp": tp, "magic": magic, + "comment": comment, "deviation": 10 + } + })["data"] + + def check(self, symbol, volume, order_type, price, sl=0, tp=0): + return self._post("/order/check", { + "action": 1, "symbol": symbol, "volume": volume, + "order_type": order_type, "price": price, + "sl": sl, "tp": tp, "magic": 0, "comment": "", "deviation": 10 + })["data"] + + +# ══════════════════════════════════════════════ +# 策略示例:均线金叉死叉 +# ══════════════════════════════════════════════ + +class MAStrategy: + def __init__(self, bridge, symbol, fast=20, slow=60): + self.bridge = bridge + self.symbol = symbol + self.fast = fast + self.slow = slow + + def signal(self): + """计算信号:1=买入, -1=卖出, 0=观望""" + df = self.bridge.to_df(self.symbol, "H1", self.slow + 5) + df["ma_fast"] = df["close"].rolling(self.fast).mean() + df["ma_slow"] = df["close"].rolling(self.slow).mean() + + # 使用最近两根已收盘 K 线 + prev = df.iloc[-3] + curr = df.iloc[-2] + + # 金叉 + if prev["ma_fast"] <= prev["ma_slow"] and curr["ma_fast"] > curr["ma_slow"]: + return 1 + # 死叉 + if prev["ma_fast"] >= prev["ma_slow"] and curr["ma_fast"] < curr["ma_slow"]: + return -1 + return 0 + + def run(self): + sig = self.signal() + bid, ask = self.bridge.tick(self.symbol) + acc = self.bridge.account() + print(f"[{datetime.now()}] {self.symbol} Bid:{bid} Ask:{ask} " + f"Balance:{acc['balance']} Equity:{acc['equity']} Signal:{sig}") + + if sig == 1 and not self.bridge.has_position(self.symbol): + print(" → 金叉,开多") + self.bridge.buy(self.symbol, 0.01, ask, sl=ask - 50, tp=ask + 100) + elif sig == -1 and not self.bridge.has_position(self.symbol): + print(" → 死叉,开空") + self.bridge.sell(self.symbol, 0.01, bid, sl=bid + 50, tp=bid - 100) + + +# ══════════════════════════════════════════════ +# 运行 +# ══════════════════════════════════════════════ + +if __name__ == "__main__": + bridge = Mt5Bridge() + strategy = MAStrategy(bridge, "XAUUSD", fast=20, slow=60) + + while True: + try: + strategy.run() + except Exception as e: + print(f"Error: {e}") + time.sleep(60) # 每分钟检查一次 +``` + +--- + +## 浏览器快速验证 + +在浏览器地址栏直接输入: + +``` +http://61.164.252.86:13485/health?key=your-api-key +http://61.164.252.86:13485/account?key=your-api-key +http://61.164.252.86:13485/symbols/XAUUSD/tick?key=your-api-key +http://61.164.252.86:13485/rates/from-date?symbol=XAUUSD&timeframe=TIMEFRAME_H1&date_from=2026-07-01&date_to=2026-07-03&key=your-api-key +``` + +--- + +## PowerShell 快速测试 + +```powershell +$headers = @{ "X-API-Key" = "your-api-key" } +Invoke-RestMethod "http://61.164.252.86:13485/health" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/account" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/symbols/XAUUSD/tick" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/rates/from-date?symbol=XAUUSD&timeframe=TIMEFRAME_H1&date_from=2026-07-01&date_to=2026-07-03" -Headers $headers +``` + +--- + +## 常见问题 + +> **第一次调这个 bridge?先去读 [§「⚠️ 隐含约定与已知陷阱」](#-隐含约定与已知陷阱先读)** —— 6 个非显而易见的约定(date_to 排他、`entry` 字段语义反、`/order/send` 不返回 fill 价 等),不读这一节直接调接口几乎必踩。 + +### 返回 "Unauthorized" +API Key 错误或没带。检查 Header 中的 `X-API-Key` 或 URL 中的 `?key=`。 + +### 返回 "对于该符号,不支持市场执行" +`order_type` 填错了,MT5 中有些品种不支持市价单,有些不支持挂单。先调用 `/order/check` 预检。 + +### 返回 "没有足够的资金" +保证金不足,减小手数或检查 `account.margin_free`。 + +### 闭仓 P&L 永远是 0 / 跟 MT5 terminal 对不上 +大概率是过滤了 `entry==0` 的 deal(以为 OUT),实际这个 bridge 是 `entry==1` 才是 OUT(关仓)。详见 §隐含约定 P2。 + +### 算出来的 P&L 跟 broker 对差 1.4× / 1.5× +多半是手动把 `positions.profit` 当 quote currency 处理。`positions.profit` 是 deposit currency(USD),不是 quote currency。详见 §隐含约定 P4。 + +### 返回 "无法连接到远程服务器" +Bridge 未运行或网络不通,先检查 `/health`。 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce3d4d3 --- /dev/null +++ b/README.md @@ -0,0 +1,742 @@ +# RaptorBT + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://uppercase.org/licenses/MIT) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![Rust](https://img.shields.io/badge/rust-1.70+-red.svg)](https://www.rust-lang.org/) + +**高性能 Rust 回测引擎,亚毫秒级回测,80+ 技术指标,位级确定性执行。** + +RaptorBT 是一个用 Rust 编写的高性能回测引擎,通过 PyO3 提供 Python 绑定。支持单标的、篮子、配对、期权、价差、多策略、Tick 级回测,在亚毫秒级时间内返回完整的 33 项绩效指标报告。 + +

+ 亚毫秒级回测 · 编译后 < 1 MB · 80+ 技术指标 · 位级确定性 · 原生并行 +

+ +--- + +## 快速开始 + +### 安装 + +```bash +pip install raptorbt +``` + +### 30 秒示例 + +```python +import numpy as np +import raptorbt + +# 配置回测 +config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001) + +# 运行回测 +result = raptorbt.run_single_backtest( + timestamps=timestamps, + open=open, high=high, low=low, close=close, volume=volume, + entries=entries, exits=exits, + direction=1, weight=1.0, symbol="AAPL", + config=config, +) + +# 查看结果 +print(f"收益率: {result.metrics.total_return_pct:.2f}%") +print(f"夏普比率: {result.metrics.sharpe_ratio:.2f}") +print(f"最大回撤: {result.metrics.max_drawdown_pct:.2f}%") +``` + +--- + +## 目录 + +- [概述](#概述) +- [性能](#性能) +- [策略类型](#策略类型) +- [技术指标](#技术指标) +- [止损与止盈](#止损与止盈) +- [蒙特卡洛组合模拟](#蒙特卡洛组合模拟) +- [回测结果与指标](#回测结果与指标) +- [API 参考](#api-参考) +- [从源码构建](#从源码构建) +- [版本历史](#版本历史) + +--- + +## 概述 + +RaptorBT 编译为单一原生扩展,完全在 Rust 中运行。在典型 K 线数量下,完整的回测加上所有 33 项绩效指标的执行时间不足 1 毫秒。在 Apple M4 上测量的基准数据(raptorbt 0.4.1): + +| 指标 | RaptorBT | +|------|----------| +| **编译引擎大小** | < 1 MB | +| **回测速度 (1K 根 K 线)** | ~0.03 ms | +| **回测速度 (10K 根 K 线)** | ~0.25 ms | +| **回测速度 (50K 根 K 线)** | ~1.4 ms | +| **内存使用** | 低(原生内存管理) | + +### 核心特性 + +- **7 种策略类型**:单标的、篮子/集体、配对交易、期权、价差、多策略、Tick 级 +- **资产与券商无关**:从任何数据源传入 NumPy OHLCV 或 Tick 数组——股票、期货、外汇、加密货币、期权,RaptorBT 从不假设市场或数据供应商 +- **80+ 技术指标**:集成 ferro-ta 指标库,覆盖趋势、动量、波动率、强度、成交量、价格变换、统计、周期变换、市场状态检测、投资组合工具 +- **Tick 级模拟**:全 Tick 分辨率,支持日内期权动量、剥头皮和微观结构策略 +- **批量价差回测**:通过 Rayon 并行运行多个价差回测,释放 GIL +- **蒙特卡洛模拟**:基于 GBM + Cholesky 分解的相关多资产前向投影 +- **33 项绩效指标**:夏普、索提诺、卡玛、Omega、SQN、盈亏比、恢复因子等 +- **止损/止盈管理**:固定、ATR 基于和追踪止损,风险回报目标 +- **位级确定性**:相同输入产生 bit-for-bit 相同结果——无 JIT 编译偏差 +- **原生并行**:Rayon 并行处理 + SIMD 优化 + +--- + +## 技术指标 + +RaptorBT 提供 **80+ 个技术指标**,其中 12 个为原版指标,其余 68 个来自 ferro-ta 指标库。所有指标均以原生 Rust 实现,接受 NumPy `float64` 数组并返回 NumPy 数组。预热期返回 NaN。 + +### 原版指标 (12 个) + +| 类别 | 指标 | +|------|------| +| **趋势** | SMA、EMA、Supertrend | +| **动量** | RSI、MACD、Stochastic | +| **波动率** | ATR、Bollinger Bands | +| **强度** | ADX | +| **成交量** | VWAP | +| **滚动** | Rolling Min、Rolling Max | + +### ferro-ta 扩展指标 (68 个) + +#### 趋势类 (15 个) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `wma` | `wma(data, period)` | `ndarray` | 加权移动平均 | +| `dema` | `dema(data, period)` | `ndarray` | 双重指数移动平均 | +| `tema` | `tema(data, period)` | `ndarray` | 三重指数移动平均 | +| `kama` | `kama(data, period)` | `ndarray` | Kaufman 自适应移动平均 | +| `t3` | `t3(data, period=5, vfactor=0.7)` | `ndarray` | Tillson T3 移动平均 | +| `trima` | `trima(data, period)` | `ndarray` | 三角移动平均 | +| `midpoint` | `midpoint(data, period)` | `ndarray` | 周期内中点值 | +| `midprice` | `midprice(high, low, period)` | `ndarray` | 周期内最高/最低均价 | +| `sar` | `sar(high, low, acceleration=0.02, maximum=0.2)` | `ndarray` | 抛物线 SAR | +| `hull_ma` | `hull_ma(data, period)` | `ndarray` | Hull 移动平均 | +| `donchian` | `donchian(high, low, period)` | `(upper, middle, lower)` | 唐奇安通道 | +| `choppiness_index` | `choppiness_index(high, low, close, period=14)` | `ndarray` | 混沌指标 (0=趋势, 100=震荡) | +| `chandelier_exit` | `chandelier_exit(high, low, close, period=22, multiplier=3.0)` | `(long_exit, short_exit)` | 吊灯止损 (ATR 追踪) | +| `ichimoku` | `ichimoku(high, low, close, tenkan=9, kijun=26, senkou_b=52, displacement=26)` | `(tenkan, kijun, senkou_a, senkou_b, chikou)` | 一目均衡表 | +| `pivot_points` | `pivot_points(high, low, close, method="classic")` | `(pivot, r1, s1, r2, s2)` | 枢轴点 (classic/fibonacci/camarilla) | + +#### 动量类 (14 个) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `cci` | `cci(high, low, close, period)` | `ndarray` | 商品通道指数 | +| `willr` | `willr(high, low, close, period)` | `ndarray` | 威廉指标 (-100~0) | +| `roc` | `roc(data, period)` | `ndarray` | 变化率 | +| `mom` | `mom(data, period)` | `ndarray` | 动量 | +| `cmo` | `cmo(data, period)` | `ndarray` | 钱德动量振荡器 | +| `trix` | `trix(data, period)` | `ndarray` | 三重指数平滑变化率 | +| `stochrsi` | `stochrsi(data, timeperiod=14, fastk_period=5, fastd_period=3)` | `(fastk, fastd)` | 随机 RSI (0~100) | +| `aroon` | `aroon(high, low, period)` | `(up, down)` | Aroon 上升/下降 (0~100) | +| `aroonosc` | `aroonosc(high, low, period)` | `ndarray` | Aroon 振荡器 (-100~100) | +| `bop` | `bop(open, high, low, close)` | `ndarray` | 力量平衡 (-1~1) | +| `ultosc` | `ultosc(high, low, close, period1=7, period2=14, period3=28)` | `ndarray` | 终极振荡器 (0~100) | +| `ppo` | `ppo(data, fastperiod=12, slowperiod=26, signalperiod=9)` | `(line, signal, hist)` | 百分比价格振荡器 | +| `apo` | `apo(data, fastperiod=12, slowperiod=26)` | `ndarray` | 绝对价格振荡器 | +| `adx_all` | `adx_all(high, low, close, period)` | `(adx, +di, -di)` | ADX + DI+ + DI- | + +#### 波动率类 (5 个) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `natr` | `natr(high, low, close, period)` | `ndarray` | 归一化 ATR (%) | +| `trange` | `trange(high, low, close)` | `ndarray` | 真实波幅 | +| `stddev` | `stddev(data, period, nbdev=1.0)` | `ndarray` | 标准差 | +| `var` | `var(data, period, nbdev=1.0)` | `ndarray` | 方差 | +| `atr` | `atr(high, low, close, period)` | `ndarray` | 平均真实波幅 (原版) | + +#### 强度类 (3 个) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `adx` | `adx(high, low, close, period)` | `ndarray` | ADX (0~100) (原版) | +| `plus_di` | `plus_di(high, low, close, period)` | `ndarray` | +DI 方向指标 | +| `minus_di` | `minus_di(high, low, close, period)` | `ndarray` | -DI 方向指标 | +| `adxr` | `adxr(high, low, close, period)` | `ndarray` | ADX 评级 | + +#### 成交量类 (5 个) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `ad` | `ad(high, low, close, volume)` | `ndarray` | 累积/派发线 | +| `adosc` | `adosc(high, low, close, volume, fastperiod=3, slowperiod=10)` | `ndarray` | 累积/派发振荡器 | +| `obv` | `obv(close, volume)` | `ndarray` | 能量潮 | +| `mfi` | `mfi(high, low, close, volume, period)` | `ndarray` | 资金流量指数 (0~100) | +| `vwma` | `vwma(data, volume, period=20)` | `ndarray` | 成交量加权移动平均 | + +#### 价格变换类 (4 个) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `typprice` | `typprice(high, low, close)` | `ndarray` | 典型价格 (H+L+C)/3 | +| `medprice` | `medprice(high, low)` | `ndarray` | 中间价格 (H+L)/2 | +| `avgprice` | `avgprice(open, high, low, close)` | `ndarray` | 平均价格 (O+H+L+C)/4 | +| `wclprice` | `wclprice(high, low, close)` | `ndarray` | 加权收盘价 (H+L+C*2)/4 | + +#### 统计类 (7 个) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `linearreg` | `linearreg(data, period)` | `ndarray` | 线性回归 | +| `linearreg_slope` | `linearreg_slope(data, period)` | `ndarray` | 线性回归斜率 | +| `linearreg_angle` | `linearreg_angle(data, period)` | `ndarray` | 线性回归角度 | +| `linearreg_intercept` | `linearreg_intercept(data, period)` | `ndarray` | 线性回归截距 | +| `tsf` | `tsf(data, period)` | `ndarray` | 时间序列预测 | +| `beta` | `beta(data0, data1, period)` | `ndarray` | Beta 系数 | +| `correl` | `correl(data0, data1, period)` | `ndarray` | 相关系数 | + +#### Hilbert 变换 (6 个) + +基于希尔伯特变换的周期分析工具(需要至少 32 根 K 线): + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `ht_trendline` | `ht_trendline(data)` | `ndarray` | 希尔伯特瞬时趋势线 | +| `ht_dcperiod` | `ht_dcperiod(data)` | `ndarray` | 主导周期周期 | +| `ht_dcphase` | `ht_dcphase(data)` | `ndarray` | 主导周期相位(度) | +| `ht_phasor` | `ht_phasor(data)` | `(in_phase, quadrature)` | 相量分量 | +| `ht_sine` | `ht_sine(data)` | `(sine, lead_sine)` | 正弦波(含超前信号) | +| `ht_trendmode` | `ht_trendmode(data)` | `ndarray[i32]` | 趋势/周期模式 (1=趋势, 0=周期) | + +#### 市场状态检测 (4 个) + +用于判断当前市场处于趋势或震荡状态,以及检测结构性突变: + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `regime_adx` | `regime_adx(adx, threshold=25.0)` | `ndarray[i8]` | 基于 ADX 的趋势/震荡标签 (1=趋势, 0=震荡, -1=预热) | +| `regime_combined` | `regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=2.0)` | `ndarray[i8]` | ADX+ATR 组合判断 | +| `detect_breaks_cusum` | `detect_breaks_cusum(data, window, threshold, slack)` | `ndarray[i8]` | CUSUM 结构性突变检测 (1=突变点) | +| `rolling_variance_break` | `rolling_variance_break(data, short_window, long_window, threshold)` | `ndarray[i8]` | 滚动方差比突变检测 (1=突变点) | + +#### 投资组合工具 (6 个) + +跨序列分析工具,用于配对交易、风险管理、相对强度计算: + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `rolling_beta` | `rolling_beta(asset, benchmark, window)` | `ndarray` | 滚动 Beta 系数 | +| `drawdown_series` | `drawdown_series(equity)` | `(dd_series, max_dd)` | 回撤序列 + 最大回撤 | +| `zscore_series` | `zscore_series(data, window)` | `ndarray` | 滚动 Z-Score | +| `relative_strength` | `relative_strength(asset_returns, benchmark_returns)` | `ndarray` | 相对强度 (excess return 风格) | +| `spread` | `spread(a, b, hedge)` | `ndarray` | 价差序列 (a - hedge*b) | +| `ratio` | `ratio(a, b)` | `ndarray` | 比率序列 (a/b) | + +#### Tick 微结构函数 (8 个) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `tick_spread_pct` | `tick_spread_pct(bid, ask)` | `ndarray` | 每 Tick 买卖价差百分比 | +| `buy_sell_imbalance_delta` | `buy_sell_imbalance_delta(buy_cum, sell_cum)` | `ndarray` | 每 Tick 买卖失衡 | +| `return_window` | `return_window(timestamps_ns, ltp, window_seconds=60.0)` | `ndarray` | 时间窗口回看收益率 | +| `realized_vol_rolling` | `realized_vol_rolling(timestamps_ns, ltp, window_seconds=300.0)` | `ndarray` | 滚动已实现波动率 | +| `oi_position_pct` | `oi_position_pct(oi, oi_day_high, oi_day_low)` | `ndarray` | OI 位置百分比 [0, 100] | +| `tick_velocity` | `tick_velocity(timestamps_ns, window_seconds=60.0)` | `ndarray` | Tick 速度 (ticks/min) | +| `compute_tick_entry_signals` | `compute_tick_entry_signals(spread_pct, bsi_delta, return_1m, ...)` | `ndarray[bool]` | Tick 入场信号 | +| `compute_tick_exit_signals` | `compute_tick_exit_signals(timestamps_ns, eod_exit_time_ns=0)` | `ndarray[bool]` | Tick 出场信号 | + +### 用法示例 + +```python +import raptorbt +import numpy as np + +close = np.array([...], dtype=np.float64) +high = np.array([...], dtype=np.float64) +low = np.array([...], dtype=np.float64) +open_ = np.array([...], dtype=np.float64) +volume = np.array([...], dtype=np.float64) + +# 趋势 +sma20 = raptorbt.sma(close, 20) +hull_ma = raptorbt.hull_ma(close, 14) +donchian_upper, donchian_middle, donchian_lower = raptorbt.donchian(high, low, 20) +ichimoku = raptorbt.ichimoku(high, low, close) + +# 动量 +rsi14 = raptorbt.rsi(close, 14) +macd_line, macd_signal, macd_hist = raptorbt.macd(close, 12, 26, 9) +cci20 = raptorbt.cci(high, low, close, 20) +ppo_line, ppo_signal, ppo_hist = raptorbt.ppo(close) + +# 波动率 +atr14 = raptorbt.atr(high, low, close, 14) +natr14 = raptorbt.natr(high, low, close, 14) +bb_upper, bb_middle, bb_lower = raptorbt.bollinger_bands(close, 20, 2.0) + +# 成交量 +ad_line = raptorbt.ad(high, low, close, volume) +mfi14 = raptorbt.mfi(high, low, close, volume, 14) + +# Hilbert +ht_trendline = raptorbt.ht_trendline(close) +sine, lead_sine = raptorbt.ht_sine(close) + +# 市场状态 +adx_vals = raptorbt.adx(high, low, close, 14) +regime = raptorbt.regime_adx(adx_vals, threshold=25.0) + +# 投资组合 +rolling_beta = raptorbt.rolling_beta(close, benchmark, 20) +dd_series, max_dd = raptorbt.drawdown_series(equity_curve) +``` + +--- + +## 策略类型 + +### 1. 单标的回测 + +```python +config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001, slippage=0.0005) +config.set_fixed_stop(0.02) # 2% 止损 +config.set_fixed_target(0.04) # 4% 止盈 + +result = raptorbt.run_single_backtest( + timestamps=timestamps, open=open, high=high, low=low, close=close, volume=volume, + entries=entries, exits=exits, direction=1, weight=1.0, symbol="AAPL", config=config, + instrument_config=raptorbt.PyInstrumentConfig(lot_size=1.0), # 可选 +) +``` + +### 2. 篮子回测 + +多标的同步信号交易: + +```python +instruments = [ + (ts, o1, h1, l1, c1, v1, ent1, ext1, 1, 0.33, "AAPL"), + (ts, o2, h2, l2, c2, v2, ent2, ext2, 1, 0.33, "GOOGL"), + (ts, o3, h3, l3, c3, v3, ent3, ext3, 1, 0.34, "MSFT"), +] + +result = raptorbt.run_basket_backtest( + instruments=instruments, + config=config, + sync_mode="all", # "all" | "any" | "majority" | "master" +) +``` + +### 3. 配对交易 + +做多一个标的,做空另一个: + +```python +result = raptorbt.run_pairs_backtest( + leg1_timestamps=ts, leg1_open=o1, leg1_high=h1, leg1_low=l1, leg1_close=c1, leg1_volume=v1, + leg2_timestamps=ts, leg2_open=o2, leg2_high=h2, leg2_low=l2, leg2_close=c2, leg2_volume=v2, + entries=entries, exits=exits, direction=1, symbol="PAIR", config=config, + hedge_ratio=1.5, # 空头 1.5 倍 + dynamic_hedge=False, +) +``` + +### 4. 期权回测 + +```python +result = raptorbt.run_options_backtest( + timestamps=ts, open=o, high=h, low=l, close=c, volume=v, + option_prices=option_premiums, entries=entries, exits=exits, + direction=1, symbol="NIFTY_CE", config=config, + option_type="call", # "call" | "put" + strike_selection="atm", # "atm" | "otm1" | "otm2" | "itm1" | "itm2" + size_type="percent", # "percent" | "contracts" | "notional" | "risk" + size_value=0.1, + lot_size=50, +) +``` + +### 5. 多策略回测 + +同一标的上组合多个策略: + +```python +strategies = [ + (entries_sma, exits_sma, 1, 0.4, "SMA_Cross"), + (entries_rsi, exits_rsi, 1, 0.35, "RSI_MeanRev"), + (entries_bb, exits_bb, 1, 0.25, "BB_Break"), +] + +result = raptorbt.run_multi_backtest( + timestamps=ts, open=o, high=h, low=l, close=c, volume=v, + strategies=strategies, + config=config, + combine_mode="any", # "any" | "all" | "majority" | "weighted" | "independent" +) +``` + +### 6. 批量价差回测 + +Rayon 并行执行多个价差回测,GIL 释放: + +```python +items = [ + raptorbt.PyBatchSpreadItem( + strategy_id="straddle_24000", + legs_premiums=[call_premiums, put_premiums], + leg_configs=[("CE", 24000.0, -1, 50), ("PE", 24000.0, -1, 50)], + entries=entries, exits=exits, + spread_type="straddle", + max_loss=5000.0, target_profit=3000.0, + ), +] + +results = raptorbt.batch_spread_backtest( + timestamps=ts, underlying_close=close, + items=items, config=config, +) + +for sid, result in results: + print(f"{sid}: {result.metrics.total_return_pct:.2f}%") +``` + +### 7. Tick 级回测 + +全 Tick 分辨率模拟,无 K 线重采样: + +```python +result = raptorbt.run_tick_backtest( + timestamps=timestamps_ns, # int64 纳秒 + ltp=ltp_arr, bid=bid_arr, ask=ask_arr, + buy_qty_delta=buy_delta, sell_qty_delta=sell_delta, + oi=oi_arr, + entries=entry_signals, exits=exit_signals, + symbol="TICK", + initial_capital=100000.0, fees=0.001, slippage=0.0005, + stop_loss_pct=5.0, take_profit_pct=10.0, + max_hold_seconds=1800, entry_cooldown_ticks=10, max_trades=50, +) +``` + +> **Zerodha 数据注意**:`total_buy_qty` / `total_sell_qty` 是累计值,需先转换: +> `buy_delta = np.diff(buy_cum, prepend=0).clip(min=0)` + +--- + +## 止损与止盈 + +### 固定百分比 + +```python +config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001) +config.set_fixed_stop(0.02) # 2% 止损 +config.set_fixed_target(0.04) # 4% 止盈 +``` + +### ATR 动态止损 + +```python +config.set_atr_stop(multiplier=2.0, period=14) # 2倍 ATR 止损 +config.set_atr_target(multiplier=3.0, period=14) # 3倍 ATR 止盈 +``` + +### 追踪止损 + +```python +config.set_trailing_stop(0.02) # 2% 追踪止损 +``` + +### 风险回报比止盈 + +```python +config.set_risk_reward_target(ratio=2.0) # 2:1 风险回报比 +``` + +### 出场原因 + +| 值 | 含义 | +|----|------| +| `Signal` | 策略信号出场 | +| `StopLoss` | 触发止损 | +| `TakeProfit` | 触发止盈 | +| `TrailingStop` | 触发追踪止损 | +| `EndOfData` | 数据结束 | +| `Settlement` | 期权结算 | +| `TimeExit` | 超时出场(Tick 级) | + +--- + +## 蒙特卡洛组合模拟 + +```python +result = raptorbt.simulate_portfolio_mc( + returns=[ret1, ret2], # 各策略/资产的历史日收益率数组 + weights=np.array([0.6, 0.4]), # 组合权重(和为 1) + correlation_matrix=[ # N×N 相关系数矩阵 + np.array([1.0, 0.3]), + np.array([0.3, 1.0]), + ], + initial_value=100000.0, + n_simulations=10000, # 模拟路径数 + horizon_days=252, # 前瞻天数 + seed=42, # 随机种子 +) + +print(f"预期收益: {result['expected_return']:.2f}%") +print(f"亏损概率: {result['probability_of_loss']:.2%}") +print(f"VaR (95%): {result['var_95']:.2f}%") +print(f"CVaR (95%): {result['cvar_95']:.2f}%") +``` + +--- + +## 回测结果与指标 + +### PyBacktestResult + +```python +result.metrics # PyBacktestMetrics 对象 +result.equity_curve() # 权益曲线 ndarray +result.drawdown_curve() # 回撤曲线 ndarray +result.returns() # 收益率序列 ndarray +result.trades() # 交易列表 List[PyTrade] +``` + +### PyBacktestMetrics(33 个字段) + +**核心绩效**:`total_return_pct`、`sharpe_ratio`、`sortino_ratio`、`calmar_ratio`、`omega_ratio` + +**回撤**:`max_drawdown_pct`、`max_drawdown_duration` + +**交易统计**:`total_trades`、`total_closed_trades`、`total_open_trades`、`winning_trades`、`losing_trades`、`win_rate_pct` + +**交易绩效**:`profit_factor`、`expectancy`、`sqn`、`avg_trade_return_pct`、`avg_win_pct`、`avg_loss_pct`、`best_trade_pct`、`worst_trade_pct` + +**持仓**:`avg_holding_period`、`avg_winning_duration`、`avg_losing_duration` + +**连战**:`max_consecutive_wins`、`max_consecutive_losses` + +**其他**:`start_value`、`end_value`、`total_fees_paid`、`open_trade_pnl`、`exposure_pct`、`payoff_ratio`、`recovery_factor` + +```python +m = result.metrics +stats = m.to_dict() # 24 个常用指标的字典(带中文友好标签) +``` + +### PyTrade + +```python +for trade in result.trades(): + trade.id # 交易 ID + trade.symbol # 标的 + trade.entry_idx # 入场 bar 索引 + trade.exit_idx # 出场 bar 索引 + trade.entry_price # 入场价 + trade.exit_price # 出场价 + trade.size # 仓位大小 + trade.direction # 1=多, -1=空 + trade.pnl # 盈亏金额 + trade.return_pct # 收益率 (%) + trade.fees # 手续费 + trade.exit_reason # 出场原因 +``` + +--- + +## API 参考 + +### PyBacktestConfig + +```python +config = raptorbt.PyBacktestConfig( + initial_capital=100000.0, # 初始资金 + fees=0.001, # 手续费率 + slippage=0.0, # 滑点 + upon_bar_close=True, # K 线收盘后执行(防止前视偏差) +) + +# 止损方法 +config.set_fixed_stop(percent: float) +config.set_atr_stop(multiplier: float, period: int) +config.set_trailing_stop(percent: float) + +# 止盈方法 +config.set_fixed_target(percent: float) +config.set_atr_target(multiplier: float, period: int) +config.set_risk_reward_target(ratio: float) +``` + +### PyInstrumentConfig + +每标的配置: + +```python +inst = raptorbt.PyInstrumentConfig( + lot_size=1.0, # 最小交易单位 + alloted_capital=50000.0, # 分配资金(可选) + existing_qty=None, # 现有持仓(预留) + avg_price=None, # 现有均价(预留) +) + +# 可选:每标的止损/止盈覆盖 +inst.set_fixed_stop(0.02) +inst.set_trailing_stop(0.03) +``` + +### PyBatchSpreadItem + +```python +item = raptorbt.PyBatchSpreadItem( + strategy_id="straddle_24000", + legs_premiums=[call_premiums, put_premiums], + leg_configs=[("CE", 24000.0, -1, 50), ("PE", 24000.0, -1, 50)], + entries=entries, exits=exits, spread_type="straddle", + max_loss=5000.0, target_profit=3000.0, +) +``` + +### simulate_portfolio_mc + +```python +result = raptorbt.simulate_portfolio_mc( + returns=List[np.ndarray], # 各资产日收益率 (N 个数组) + weights=np.ndarray, # 组合权重 (长度 N, 和为 1) + correlation_matrix=List[np.ndarray], # N×N 相关系数矩阵 + initial_value=float, # 初始组合价值 + n_simulations=int=10000, # 模拟路径数 + horizon_days=int=252, # 前瞻天数 + seed=int=42, # 随机种子 +) -> dict +``` + +返回字典包含:`expected_return`、`probability_of_loss`、`var_95`、`cvar_95`、`percentile_paths`、`final_values`。 + +--- + +## 从源码构建 + +大多数用户应使用 `pip install raptorbt`。要自行构建,需要 Rust 1.70+、Python 3.10+ 和 maturin: + +```powershell +cd raptorbt +$env:CARGO = "C:\Users\Administrator\.cargo\bin\cargo.exe" +maturin develop --release # 开发安装到当前虚拟环境 +cargo test # 运行 Rust 测试套件 +``` + +> **注意**:如果在 Windows 上遇到「拒绝访问 (os error 5)」错误,需要设置 `CARGO` 环境变量指向 `cargo.exe` 可执行文件(而非目录)。参见 [使用手册 - 常见编译问题](https://github.com/your-repo/raptorbt/blob/main/RaptorBT使用手册.md#常见编译问题)。 + +### 构建并安装到全局 + +```powershell +# 构建 whl +maturin build --release + +# 全局 pip 安装 +pip install --force-reinstall target\wheels\raptorbt-0.4.1-cp312-cp312-win_amd64.whl +``` + +### 验证测试 + +一个带种子的冒烟测试——运行两次,结果完全一致: + +```python +import numpy as np +import raptorbt + +np.random.seed(42) +n = 500 +close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100 +entries = np.zeros(n, dtype=bool); entries[::20] = True +exits = np.zeros(n, dtype=bool); exits[10::20] = True + +config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001) +result = raptorbt.run_single_backtest( + timestamps=np.arange(n, dtype=np.int64), + open=close, high=close, low=close, close=close, volume=np.ones(n), + entries=entries, exits=exits, direction=1, weight=1.0, symbol="TEST", + config=config, +) +print(f"总收益率: {result.metrics.total_return_pct:.4f}%") # -30.6192% +print(f"夏普比率: {result.metrics.sharpe_ratio:.4f}") # -0.9086 +``` + +--- + +## 版本历史 + +### v0.4.1 + +- **新增 68 个扩展指标**:集成 ferro-ta 指标库 + - P0 扩展:VWMA、Donchian、Choppiness Index、Hull MA、Chandelier Exit、Ichimoku、Pivot Points + - Hilbert 变换:HT Trendline、DCPeriod、DCPhase、Phasor、Sine、TrendMode + - 市场状态检测:Regime ADX、Regime Combined、CUSUM Breaks、Variance Ratio Breaks + - 投资组合工具:Rolling Beta、Drawdown Series、Z-Score Series、Relative Strength、Spread、Ratio +- 指标总数从 12 扩展到 80+ + +### v0.4.0 + +- **Tick 级回测**:全 Tick 分辨率,无需 K 线重采样 + - `TickData` 结构:`timestamps`、`ltp`、`bid`、`ask`、`buy_qty_delta`、`sell_qty_delta`、`oi` + - `ExitReason::TimeExit`:最大持仓时间超时退出 + - `run_tick_backtest`:Tick 原生模拟引擎 + - `compute_tick_entry_signals`:从特征数组计算入场信号 + - `compute_tick_exit_signals`:基于时间的出场信号 + - `tick_spread_pct`、`buy_sell_imbalance_delta`、`return_window`、`realized_vol_rolling`、`oi_position_pct`、`tick_velocity` +- 公开 `compute_backtest_metrics` 函数 + +### v0.3.4 + +- 单腿期权价差:`LongCall`、`LongPut`、`NakedCall`、`NakedPut` +- `ExitReason::Settlement`:期权到期结算退出 +- `leg_expiry_timestamps`:每条腿的到期时间追踪 + +### v0.3.3 + +- `batch_spread_backtest`:通过 Rayon 并行运行多个价差回测 +- `PyBatchSpreadItem`:批量价差回测项定义 +- GIL 释放,最大 Python 并发 + +### v0.3.2 + +- `payoff_ratio` 指标:平均盈利交易收益 / 平均亏损交易收益(绝对值) +- `recovery_factor` 指标:净利润 / 最大回撤(绝对值) + +### v0.3.1 + +- 蒙特卡洛组合模拟(`simulate_portfolio_mc`) +- 几何布朗运动 + Cholesky 分解,Rayon 并行 + +### v0.3.0 + +- `PyInstrumentConfig`:每标的的配置(lot_size、分配资金、止损/止盈覆盖) +- 仓位大小正确取整到 lot_size 的倍数 + +### v0.2.2 + +- 导出 `run_spread_backtest`、`rolling_min`、`rolling_max` + +### v0.2.1 + +- 添加 `rolling_min` 和 `rolling_max`(LLV / HHV) + +### v0.2.0 + +- 多腿价差回测(straddle、strangle、vertical、iron condor、iron butterfly、butterfly、calendar、diagonal) +- 会话跟踪器(NSE 股票、MCX 商品、CDS 货币) +- `StreamingMetrics`:权益/回撤追踪、交易记录、`finalize()` + +### v0.1.0 + +- 初始版本 +- 5 种策略类型、30+ 绩效指标、10 个技术指标 +- 止损管理:固定、ATR、追踪 +- 止盈管理:固定、ATR、风险回报 +- PyO3 Python 绑定 + +--- + +## 许可证 + +MIT License - 详见 [LICENSE](LICENSE) \ No newline at end of file diff --git a/RaptorBT使用手册.md b/RaptorBT使用手册.md new file mode 100644 index 0000000..1edd99e --- /dev/null +++ b/RaptorBT使用手册.md @@ -0,0 +1,991 @@ +# RaptorBT 使用手册(含 ferro-ta 扩展版) + +> 基于 RaptorBT v0.4.1,集成 ferro-ta 指标库,提供 80 个技术指标 + +--- + +## 目录 + +- [项目简介](#项目简介) +- [安装与部署](#安装与部署) +- [快速开始](#快速开始) +- [策略类型](#策略类型) +- [指标完整参考](#指标完整参考) + - [趋势指标 (Trend)](#趋势指标-trend) + - [动量指标 (Momentum)](#动量指标-momentum) + - [波动率指标 (Volatility)](#波动率指标-volatility) + - [强度指标 (Strength)](#强度指标-strength) + - [成交量指标 (Volume)](#成交量指标-volume) + - [价格变换指标 (Price Transform)](#价格变换指标-price-transform) + - [统计指标 (Statistic)](#统计指标-statistic) + - [P0 扩展指标 (P0 Extended)](#p0-扩展指标-p0-extended) + - [周期变换指标 (Hilbert Transform / Cycle)](#周期变换指标-hilbert-transform-cycle) + - [市场状态检测 (Market Regime)](#市场状态检测-market-regime) + - [投资组合工具 (Portfolio Tools)](#投资组合工具-portfolio-tools) + - [Tick 微结构函数](#tick-微结构函数) +- [止损与止盈](#止损与止盈) +- [蒙特卡洛组合模拟](#蒙特卡洛组合模拟) +- [回测结果与指标](#回测结果与指标) +- [前视偏差防范](#前视偏差防范) +- [ sourcing修改与编译指南](#源码修改与编译指南) + - [项目结构](#项目结构) + - [添加新指标](#添加新指标) + - [编译与打包](#编译与打包) + - [常见编译问题](#常见编译问题) +- [移植与分发](#移植与分发) + +--- + +## 项目简介 + +RaptorBT 是一个高性能 Rust 回测引擎,通过 PyO3 提供 Python 绑定: + +- **亚毫秒级回测**:1K bars ~0.03ms,50K bars ~1.4ms +- **7 种策略类型**:单标的、篮子、配对、期权、价差、多策略、Tick 级 +- **33 项绩效指标**:Sharpe、Sortino、Calmar、Omega、SQN 等 +- **确定性执行**:相同输入产生 bit-for-bit 相同结果 +- **原生并行**:Rayon 并行 + SIMD 优化 +- **80 个技术指标**:集成 ferro-ta 指标库,覆盖趋势、动量、波动率、强度、成交量、价格变换、统计、周期变换、市场状态检测、投资组合工具 + +本扩展版在原版 12 个指标基础上,集成 ferro-ta 指标库,将指标总数扩展至 **51 个**。 + +--- + +## 安装与部署 + +### 方式一:安装修改版 whl(推荐) + +适用于同平台同 Python 版本的电脑,无需编译环境: + +```powershell +pip install raptorbt-0.4.1-cp312-cp312-win_amd64.whl +pip install -r requirements.txt +``` + +`requirements.txt` 内容: + +``` +numpy +pandas +requests +``` + +> **注意**:whl 文件名中 `cp312` 表示 CPython 3.12,`win_amd64` 表示 Windows 64位。目标电脑必须满足相同条件。 + +### 方式二:从源码编译 + +需要 Rust 1.70+、Python 3.10+、maturin: + +```powershell +cd raptorbt +pip install maturin +maturin develop --release +``` + +### 验证安装 + +```python +import raptorbt +print(raptorbt.__version__) # 0.4.1 +print(raptorbt.cci) # — 扩展指标可用 +``` + +--- + +## 快速开始 + +```python +import numpy as np +import raptorbt + +# 准备数据 +n = 500 +close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100 +timestamps = np.arange(n, dtype=np.int64) +open_ = close * 1.001 +high = close * 1.01 +low = close * 0.99 +volume = np.ones(n) * 1000 + +# 使用指标生成信号 +sma_fast = raptorbt.sma(close, period=10) +sma_slow = raptorbt.sma(close, period=20) +entries = (sma_fast > sma_slow) & np.roll(sma_fast <= sma_slow, 1) +exits = (sma_fast < sma_slow) & np.roll(sma_fast >= sma_slow, 1) + +# 配置回测 +config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001) + +# 运行回测 +result = raptorbt.run_single_backtest( + timestamps=timestamps, + open=open_, high=high, low=low, close=close, + volume=volume, + entries=entries, exits=exits, + direction=1, weight=1.0, symbol="TEST", + config=config, +) + +# 查看结果 +print(f"收益率: {result.metrics.total_return_pct:.2f}%") +print(f"夏普比率: {result.metrics.sharpe_ratio:.2f}") +print(f"最大回撤: {result.metrics.max_drawdown_pct:.2f}%") +print(f"交易次数: {result.metrics.total_trades}") +``` + +--- + +## 策略类型 + +### 1. 单标的回测 (run_single_backtest) + +```python +result = raptorbt.run_single_backtest( + timestamps=timestamps, # int64 数组 + open=open, high=high, low=low, close=close, # float64 数组 + volume=volume, # float64 数组 + entries=entries, # bool 数组 + exits=exits, # bool 数组 + direction=1, # 1=做多, -1=做空 + weight=1.0, + symbol="AAPL", + config=config, + instrument_config=raptorbt.PyInstrumentConfig(lot_size=1.0), # 可选 +) +``` + +### 2. 篮子回测 (run_basket_backtest) + +多标的同步信号交易: + +```python +instruments = [ + (ts, o1, h1, l1, c1, v1, entries1, exits1, 1, 0.33, "AAPL"), + (ts, o2, h2, l2, c2, v2, entries2, exits2, 1, 0.33, "GOOGL"), + (ts, o3, h3, l3, c3, v3, entries3, exits3, 1, 0.34, "MSFT"), +] + +result = raptorbt.run_basket_backtest( + instruments=instruments, + config=config, + sync_mode="all", # "all" | "any" | "majority" | "master" +) +``` + +### 3. 配对交易 (run_pairs_backtest) + +做多一个标的,做空另一个: + +```python +result = raptorbt.run_pairs_backtest( + leg1_timestamps=ts, leg1_open=..., leg1_high=..., leg1_low=..., leg1_close=..., leg1_volume=..., + leg2_timestamps=ts, leg2_open=..., leg2_high=..., leg2_low=..., leg2_close=..., leg2_volume=..., + entries=entries, exits=exits, + direction=1, symbol="PAIR", + config=config, + hedge_ratio=1.5, # 空头 1.5 倍 + dynamic_hedge=False, +) +``` + +### 4. 期权回测 (run_options_backtest) + +```python +result = raptorbt.run_options_backtest( + timestamps=ts, open=..., high=..., low=..., close=..., volume=..., + option_prices=option_premiums, + entries=entries, exits=exits, + direction=1, symbol="NIFTY_CE", + config=config, + option_type="call", # "call" | "put" + strike_selection="atm", # "atm" | "otm1" | "otm2" | "itm1" | "itm2" + size_type="percent", # "percent" | "contracts" | "notional" | "risk" + size_value=0.1, + lot_size=50, + strike_interval=50.0, +) +``` + +### 5. 多策略回测 (run_multi_backtest) + +同一标的上组合多个策略: + +```python +strategies = [ + (entries_sma, exits_sma, 1, 0.4, "SMA_Cross"), + (entries_rsi, exits_rsi, 1, 0.35, "RSI_MeanRev"), + (entries_bb, exits_bb, 1, 0.25, "BB_Break"), +] + +result = raptorbt.run_multi_backtest( + timestamps=ts, open=..., high=..., low=..., close=..., volume=..., + strategies=strategies, + config=config, + combine_mode="any", # "any" | "all" | "majority" | "weighted" | "independent" +) +``` + +### 6. 批量价差回测 (batch_spread_backtest) + +Rayon 并行执行多个价差回测,GIL 释放: + +```python +items = [ + raptorbt.PyBatchSpreadItem( + strategy_id="straddle_24000", + legs_premiums=[call_premiums, put_premiums], + leg_configs=[("CE", 24000.0, -1, 50), ("PE", 24000.0, -1, 50)], + entries=entries, exits=exits, + spread_type="straddle", + max_loss=5000.0, target_profit=3000.0, + ), +] + +results = raptorbt.batch_spread_backtest( + timestamps=ts, underlying_close=close, + items=items, config=config, +) + +for sid, result in results: + print(f"{sid}: {result.metrics.total_return_pct:.2f}%") +``` + +### 7. Tick 级回测 (run_tick_backtest) + +全 Tick 分辨率模拟,无 bar 重采样: + +```python +result = raptorbt.run_tick_backtest( + timestamps=timestamps_ns, # int64 纳秒 + ltp=ltp_arr, bid=bid_arr, ask=ask_arr, + buy_qty_delta=buy_delta, sell_qty_delta=sell_delta, + oi=oi_arr, + entries=entry_signals, exits=exit_signals, + symbol="NIFTY26APR24600PE", + initial_capital=100000.0, fees=0.001, slippage=0.0005, + stop_loss_pct=5.0, take_profit_pct=10.0, + max_hold_seconds=1800, entry_cooldown_ticks=10, max_trades=50, +) +``` + +> **Zerodha 数据注意**:`total_buy_qty` / `total_sell_qty` 是累计值,需先转换: +> `buy_delta = np.diff(buy_cum, prepend=0).clip(min=0)` + +--- + +## 指标完整参考 + +所有指标函数接收 NumPy `float64` 数组,返回 NumPy 数组。预热期返回 `NaN`。 + +### 趋势指标 (Trend) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `sma` | `sma(data, period)` | `ndarray` | 简单移动平均 | +| `ema` | `ema(data, period)` | `ndarray` | 指数移动平均 | +| `wma` | `wma(data, period)` | `ndarray` | 加权移动平均 | +| `dema` | `dema(data, period)` | `ndarray` | 双重指数移动平均 | +| `tema` | `tema(data, period)` | `ndarray` | 三重指数移动平均 | +| `kama` | `kama(data, period)` | `ndarray` | Kaufman 自适应移动平均 | +| `t3` | `t3(data, period=5, vfactor=0.7)` | `ndarray` | Tillson T3 移动平均 | +| `trima` | `trima(data, period)` | `ndarray` | 三角移动平均 | +| `midpoint` | `midpoint(data, period)` | `ndarray` | 周期内中点值 | +| `midprice` | `midprice(high, low, period)` | `ndarray` | 周期内最高/最低均价 | +| `sar` | `sar(high, low, acceleration=0.02, maximum=0.2)` | `ndarray` | 抛物线 SAR | +| `supertrend` | `supertrend(high, low, close, period=10, multiplier=3.0)` | `(ndarray, ndarray)` | Supertrend 线 + 方向 (1=多, -1=空) | + +**用法示例:** + +```python +import raptorbt +import numpy as np + +close = np.array([...], dtype=np.float64) +high = np.array([...], dtype=np.float64) +low = np.array([...], dtype=np.float64) + +sma20 = raptorbt.sma(close, period=20) +ema20 = raptorbt.ema(close, period=20) +dema20 = raptorbt.dema(close, period=20) +kama10 = raptorbt.kama(close, period=10) +t3_val = raptorbt.t3(close, period=20, vfactor=0.7) +sar_val = raptorbt.sar(high, low, acceleration=0.02, maximum=0.2) +st_line, st_dir = raptorbt.supertrend(high, low, close, period=10, multiplier=3.0) +``` + +### 动量指标 (Momentum) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `rsi` | `rsi(data, period)` | `ndarray` | 相对强弱指数 (0~100) | +| `macd` | `macd(data, fast_period=12, slow_period=26, signal_period=9)` | `(line, signal, hist)` | MACD | +| `stochastic` | `stochastic(high, low, close, k_period=14, d_period=3)` | `(k, d)` | 随机振荡器 (0~100) | +| `cci` | `cci(high, low, close, period)` | `ndarray` | 商品通道指数 | +| `willr` | `willr(high, low, close, period)` | `ndarray` | 威廉指标 (-100~0) | +| `roc` | `roc(data, period)` | `ndarray` | 变化率 | +| `mom` | `mom(data, period)` | `ndarray` | 动量 | +| `cmo` | `cmo(data, period)` | `ndarray` | 钱德动量振荡器 | +| `trix` | `trix(data, period)` | `ndarray` | 三重指数平滑变化率 | +| `stochrsi` | `stochrsi(data, timeperiod=14, fastk_period=5, fastd_period=3)` | `(fastk, fastd)` | 随机 RSI (0~100) | +| `aroon` | `aroon(high, low, period)` | `(up, down)` | Aroon 上升/下降 (0~100) | +| `aroonosc` | `aroonosc(high, low, period)` | `ndarray` | Aroon 振荡器 (-100~100) | +| `bop` | `bop(open, high, low, close)` | `ndarray` | 力量平衡 (-1~1) | +| `ultosc` | `ultosc(high, low, close, period1=7, period2=14, period3=28)` | `ndarray` | 终极振荡器 (0~100) | +| `ppo` | `ppo(data, fastperiod=12, slowperiod=26, signalperiod=9)` | `(line, signal, hist)` | 百分比价格振荡器 | +| `apo` | `apo(data, fastperiod=12, slowperiod=26)` | `ndarray` | 绝对价格振荡器 | + +**用法示例:** + +```python +rsi14 = raptorbt.rsi(close, period=14) +macd_line, macd_signal, macd_hist = raptorbt.macd(close, 12, 26, 9) +stoch_k, stoch_d = raptorbt.stochastic(high, low, close, k_period=14, d_period=3) +cci20 = raptorbt.cci(high, low, close, period=20) +ppo_line, ppo_signal, ppo_hist = raptorbt.ppo(close, fastperiod=12, slowperiod=26, signalperiod=9) +aroon_up, aroon_down = raptorbt.aroon(high, low, period=14) +``` + +### 波动率指标 (Volatility) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `atr` | `atr(high, low, close, period)` | `ndarray` | 平均真实波幅 | +| `natr` | `natr(high, low, close, period)` | `ndarray` | 归一化 ATR (%) | +| `trange` | `trange(high, low, close)` | `ndarray` | 真实波幅 | +| `bollinger_bands` | `bollinger_bands(data, period=20, std_dev=2.0)` | `(upper, middle, lower)` | 布林带 | +| `stddev` | `stddev(data, period, nbdev=1.0)` | `ndarray` | 标准差 | +| `var` | `var(data, period, nbdev=1.0)` | `ndarray` | 方差 | + +**用法示例:** + +```python +atr14 = raptorbt.atr(high, low, close, period=14) +natr14 = raptorbt.natr(high, low, close, period=14) +bb_upper, bb_middle, bb_lower = raptorbt.bollinger_bands(close, period=20, std_dev=2.0) +std20 = raptorbt.stddev(close, period=20, nbdev=1.0) +``` + +### 强度指标 (Strength) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `adx` | `adx(high, low, close, period)` | `ndarray` | 平均方向指数 (0~100) | +| `adx_all` | `adx_all(high, low, close, period)` | `(adx, +di, -di)` | ADX + 正DI + 负DI | +| `adxr` | `adxr(high, low, close, period)` | `ndarray` | ADX 评级 | +| `plus_di` | `plus_di(high, low, close, period)` | `ndarray` | +DI 方向指标 | +| `minus_di` | `minus_di(high, low, close, period)` | `ndarray` | -DI 方向指标 | + +**用法示例:** + +```python +adx14 = raptorbt.adx(high, low, close, period=14) +adx_val, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=14) +adxr14 = raptorbt.adxr(high, low, close, period=14) +``` + +### 成交量指标 (Volume) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `vwap` | `vwap(high, low, close, volume)` | `ndarray` | 成交量加权平均价 | +| `obv` | `obv(close, volume)` | `ndarray` | 能量潮 | +| `ad` | `ad(high, low, close, volume)` | `ndarray` | 累积/派发线 | +| `adosc` | `adosc(high, low, close, volume, fastperiod=3, slowperiod=10)` | `ndarray` | 累积/派发振荡器 | +| `mfi` | `mfi(high, low, close, volume, period)` | `ndarray` | 资金流量指数 (0~100) | + +**用法示例:** + +```python +vwap_val = raptorbt.vwap(high, low, close, volume) +obv_val = raptorbt.obv(close, volume) +ad_val = raptorbt.ad(high, low, close, volume) +adosc_val = raptorbt.adosc(high, low, close, volume, fastperiod=3, slowperiod=10) +mfi14 = raptorbt.mfi(high, low, close, volume, period=14) +``` + +### 价格变换指标 (Price Transform) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `typprice` | `typprice(high, low, close)` | `ndarray` | 典型价格 (H+L+C)/3 | +| `medprice` | `medprice(high, low)` | `ndarray` | 中间价格 (H+L)/2 | +| `avgprice` | `avgprice(open, high, low, close)` | `ndarray` | 平均价格 (O+H+L+C)/4 | +| `wclprice` | `wclprice(high, low, close)` | `ndarray` | 加权收盘价 (H+L+C*2)/4 | + +### 统计指标 (Statistic) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `linearreg` | `linearreg(data, period)` | `ndarray` | 线性回归 | +| `linearreg_slope` | `linearreg_slope(data, period)` | `ndarray` | 线性回归斜率 | +| `linearreg_intercept` | `linearreg_intercept(data, period)` | `ndarray` | 线性回归截距 | +| `linearreg_angle` | `linearreg_angle(data, period)` | `ndarray` | 线性回归角度 | +| `tsf` | `tsf(data, period)` | `ndarray` | 时间序列预测 | +| `beta` | `beta(data0, data1, period)` | `ndarray` | Beta 系数 | +| `correl` | `correl(data0, data1, period)` | `ndarray` | 相关系数 | + +### P0 扩展指标 (P0 Extended) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `vwma` | `vwma(data, volume, period=20)` | `ndarray` | 成交量加权移动平均 | +| `donchian` | `donchian(high, low, period)` | `(upper, middle, lower)` | 唐奇安通道 | +| `choppiness_index` | `choppiness_index(high, low, close, period=14)` | `ndarray` | 混沌指标 (0=趋势, 100=震荡) | +| `hull_ma` | `hull_ma(data, period)` | `ndarray` | Hull 移动平均 | +| `chandelier_exit` | `chandelier_exit(high, low, close, period=22, multiplier=3.0)` | `(long_exit, short_exit)` | 吊灯止损 (ATR追踪止损) | +| `ichimoku` | `ichimoku(high, low, close, tenkan_period=9, kijun_period=26, senkou_b_period=52, displacement=26)` | `(tenkan, kijun, senkou_a, senkou_b, chikou)` | 一目均衡表 | +| `pivot_points` | `pivot_points(high, low, close, method="classic")` | `(pivot, r1, s1, r2, s2)` | 枢轴点 (classic/fibonacci/camarilla) | + +**用法示例:** + +```python +vwap_val = raptorbt.vwma(close, volume, period=20) +donchian_upper, donchian_middle, donchian_lower = raptorbt.donchian(high, low, 20) +ci_val = raptorbt.choppiness_index(high, low, close, period=14) +hma_val = raptorbt.hull_ma(close, period=14) +clong, cshort = raptorbt.chandelier_exit(high, low, close, period=22, multiplier=3.0) +tenkan, kijun, senkou_a, senkou_b, chikou = raptorbt.ichimoku(high, low, close) +pivot, r1, s1, r2, s2 = raptorbt.pivot_points(high, low, close, method="classic") +``` + +### 周期变换指标 (Hilbert Transform / Cycle) + +基于希尔伯特变换的周期分析工具。要求数据至少 32 根 K 线。 + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `ht_trendline` | `ht_trendline(data)` | `ndarray` | 希尔伯特瞬时趋势线 | +| `ht_dcperiod` | `ht_dcperiod(data)` | `ndarray` | 主导周期周期 | +| `ht_dcphase` | `ht_dcphase(data)` | `ndarray` | 主导周期相位(度) | +| `ht_phasor` | `ht_phasor(data)` | `(in_phase, quadrature)` | 相量分量 | +| `ht_sine` | `ht_sine(data)` | `(sine, lead_sine)` | 正弦波(含超前信号) | +| `ht_trendmode` | `ht_trendmode(data)` | `ndarray[i32]` | 趋势/周期模式 (1=趋势, 0=周期) | + +**用法示例:** + +```python +trendline = raptorbt.ht_trendline(close) +dcperiod = raptorbt.ht_dcperiod(close) +dcphase = raptorbt.ht_dcphase(close) +in_phase, quadrature = raptorbt.ht_phasor(close) +sine, lead_sine = raptorbt.ht_sine(close) +mode = raptorbt.ht_trendmode(close) # i32: 1=trend, 0=cycle +``` + +### 市场状态检测 (Market Regime) + +用于判断当前市场处于趋势或震荡状态,以及检测结构性突变。 + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `regime_adx` | `regime_adx(adx, threshold=25.0)` | `ndarray[i8]` | 基于 ADX 的趋势/震荡标签 (1=趋势, 0=震荡, -1=预热) | +| `regime_combined` | `regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=2.0)` | `ndarray[i8]` | ADX+ATR 组合判断 | +| `detect_breaks_cusum` | `detect_breaks_cusum(data, window, threshold, slack)` | `ndarray[i8]` | CUSUM 结构性突变检测 (1=突变点) | +| `rolling_variance_break` | `rolling_variance_break(data, short_window, long_window, threshold)` | `ndarray[i8]` | 滚动方差比突变检测 (1=突变点) | + +**用法示例:** + +```python +adx_vals = raptorbt.adx(high, low, close, 14) +regime = raptorbt.regime_adx(adx_vals, threshold=25.0) # 1=trend, 0=range + +# 组合判断 +regime2 = raptorbt.regime_combined(adx_vals, atr_vals, close, 25.0, 2.0) + +# 结构突变 +breaks = raptorbt.detect_breaks_cusum(close, window=30, threshold=1.5, slack=0.5) +vol_breaks = raptorbt.rolling_variance_break(close, 10, 30, 2.0) +``` + +### 投资组合工具 (Portfolio Tools) + +跨序列分析工具,用于配对交易、风险管理、相对强度计算。 + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `rolling_beta` | `rolling_beta(asset, benchmark, window)` | `ndarray` | 滚动 Beta 系数 | +| `drawdown_series` | `drawdown_series(equity)` | `(dd_series, max_dd)` | 回撤序列 + 最大回撤 | +| `zscore_series` | `zscore_series(data, window)` | `ndarray` | 滚动 Z-Score | +| `relative_strength` | `relative_strength(asset_returns, benchmark_returns)` | `ndarray` | 相对强度 (excess return 风格) | +| `spread` | `spread(a, b, hedge)` | `ndarray` | 价差序列 (a - hedge*b) | +| `ratio` | `ratio(a, b)` | `ndarray` | 比率序列 (a/b) | + +**用法示例:** + +```python +# 滚动 Beta +rb = raptorbt.rolling_beta(asset_returns, benchmark_returns, 30) + +# 回撤分析 +dd_series, max_dd = raptorbt.drawdown_series(equity_curve) + +# Z-Score +zscore = raptorbt.zscore_series(close, window=20) + +# 配对交易价差 +spread_val = raptorbt.spread(series_a, series_b, hedge=1.0) +ratio_val = raptorbt.ratio(series_a, series_b) +``` + +### 滚动统计 + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `rolling_min` | `rolling_min(data, period)` | `ndarray` | 周期内最低值 (LLV) | +| `rolling_max` | `rolling_max(data, period)` | `ndarray` | 周期内最高值 (HHV) | + +### Tick 微结构函数 + +用于 Tick 级回测的信号和特征函数: + +```python +# 入场/出场信号 +entries = raptorbt.compute_tick_entry_signals( + spread_pct=raptorbt.tick_spread_pct(bid, ask), + bsi_delta=raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum), + return_1m=raptorbt.return_window(timestamps_ns, ltp, window_seconds=60.0), + spread_pct_max=3.0, + bsi_min=0.55, + return_1m_min_abs=0.3, + return_direction=1, + cooldown_ticks=10, +) +exits = raptorbt.compute_tick_exit_signals(timestamps_ns, eod_exit_time_ns) + +# 特征数组 +spread = raptorbt.tick_spread_pct(bid, ask) # 价差百分比 +bsi = raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum) # 买卖失衡 +ret_1m = raptorbt.return_window(ts_ns, ltp, 60.0) # 1分钟回报 +vol = raptorbt.realized_vol_rolling(ts_ns, ltp, 300.0) # 5分钟已实现波动率 +oi_pos = raptorbt.oi_position_pct(oi, oi_high, oi_low) # OI 位置百分比 +velocity = raptorbt.tick_velocity(ts_ns, 60.0) # Tick 速度 (ticks/min) +``` + +--- + +## 止损与止盈 + +### 固定百分比 + +```python +config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001) +config.set_fixed_stop(0.02) # 2% 止损 +config.set_fixed_target(0.04) # 4% 止盈 +``` + +### ATR 动态止损 + +```python +config.set_atr_stop(multiplier=2.0, period=14) # 2倍 ATR 止损 +config.set_atr_target(multiplier=3.0, period=14) # 3倍 ATR 止盈 +``` + +### 追踪止损 + +```python +config.set_trailing_stop(0.02) # 2% 追踪止损 +``` + +### 风险回报比止盈 + +```python +config.set_risk_reward_target(ratio=2.0) # 2:1 风险回报比 +``` + +### 出场原因 + +| 值 | 含义 | +|---|------| +| `Signal` | 策略信号出场 | +| `StopLoss` | 触发止损 | +| `TakeProfit` | 触发止盈 | +| `TrailingStop` | 触发追踪止损 | +| `EndOfData` | 数据结束 | +| `Settlement` | 期权结算 | +| `TimeExit` | 超时出场(Tick 级) | + +--- + +## 蒙特卡洛组合模拟 + +```python +result = raptorbt.simulate_portfolio_mc( + returns=[ret1, ret2], # 各策略/资产的历史日收益率数组 + weights=np.array([0.6, 0.4]), # 组合权重(和为1) + correlation_matrix=[ # N×N 相关系数矩阵 + np.array([1.0, 0.3]), + np.array([0.3, 1.0]), + ], + initial_value=100000.0, + n_simulations=10000, # 模拟路径数 + horizon_days=252, # 前瞻天数 + seed=42, # 随机种子 +) + +print(f"预期收益: {result['expected_return']:.2f}%") +print(f"亏损概率: {result['probability_of_loss']:.2%}") +print(f"VaR (95%): {result['var_95']:.2f}%") +print(f"CVaR (95%): {result['cvar_95']:.2f}%") +``` + +--- + +## 回测结果与指标 + +### PyBacktestResult + +```python +result.metrics # PyBacktestMetrics 对象 +result.equity_curve() # 权益曲线 ndarray +result.drawdown_curve() # 回撤曲线 ndarray +result.returns() # 收益率序列 ndarray +result.trades() # 交易列表 List[PyTrade] +``` + +### PyBacktestMetrics(33 个字段) + +**核心绩效:** + +| 字段 | 说明 | +|------|------| +| `total_return_pct` | 总收益率 (%) | +| `sharpe_ratio` | 夏普比率(年化) | +| `sortino_ratio` | 索提诺比率 | +| `calmar_ratio` | 卡玛比率 | +| `omega_ratio` | Omega 比率 | + +**回撤:** + +| 字段 | 说明 | +|------|------| +| `max_drawdown_pct` | 最大回撤 (%) | +| `max_drawdown_duration` | 最长回撤持续期 (bars) | + +**交易统计:** + +| 字段 | 说明 | +|------|------| +| `total_trades` | 总交易数 | +| `winning_trades` | 盈利交易数 | +| `losing_trades` | 亏损交易数 | +| `win_rate_pct` | 胜率 (%) | +| `profit_factor` | 盈利因子 | +| `expectancy` | 期望值 | +| `sqn` | 系统质量数 | +| `avg_trade_return_pct` | 平均交易收益 (%) | +| `avg_win_pct` | 平均盈利 (%) | +| `avg_loss_pct` | 平均亏损 (%) | +| `best_trade_pct` | 最佳交易 (%) | +| `worst_trade_pct` | 最差交易 (%) | +| `payoff_ratio` | 盈亏比 | +| `recovery_factor` | 恢复因子 | + +**其他:** + +| 字段 | 说明 | +|------|------| +| `start_value` | 初始资金 | +| `end_value` | 终值 | +| `total_fees_paid` | 总手续费 | +| `open_trade_pnl` | 未平仓盈亏 | +| `exposure_pct` | 市场暴露度 (%) | +| `avg_holding_period` | 平均持仓时间 (bars) | +| `max_consecutive_wins` | 最大连胜 | +| `max_consecutive_losses` | 最大连败 | + +```python +m = result.metrics +print(m.total_return_pct, m.sharpe_ratio, m.max_drawdown_pct) + +# 转为字典(24 个常用指标,带中文友好标签) +stats = m.to_dict() +``` + +### PyTrade + +```python +for trade in result.trades(): + trade.id # 交易 ID + trade.symbol # 标的 + trade.entry_idx # 入场 bar 索引 + trade.exit_idx # 出场 bar 索引 + trade.entry_price # 入场价 + trade.exit_price # 出场价 + trade.size # 仓位大小 + trade.direction # 1=多, -1=空 + trade.pnl # 盈亏金额 + trade.return_pct # 收益率 (%) + trade.fees # 手续费 + trade.exit_reason # 出场原因 +``` + +--- + +## 前视偏差防范 + +前视偏差(Look-ahead Bias)是回测中最危险的陷阱之一——无意中使用了"未来数据"来做出"当前决策",导致回测结果虚高。 + +### RaptorBT 的默认防护 + +RaptorBT 默认 `upon_bar_close=True`,含义是: + +- 当前 K 线收盘后产生的信号,在**下一根 K 线开盘时**执行 +- 这保证了信号生成时只能看到当前及之前的数据,不可能用到未来数据 + +### 需要额外注意的场景 + +1. **指标预热期**:前 N 根 K 线的指标值为 NaN,不要用这些 NaN 值生成信号 +2. **未来函数**:避免使用 `shift(-1)` 等"偷看未来"的操作 +3. **日线数据**:如果用日线回测,确保信号不依赖当日收盘价之后的信息 +4. **复权数据**:前复权/后复权可能引入未来信息,建议使用不复权数据 + +--- + +## 源码修改与编译指南 + +### 项目结构 + +``` +raptorbt/ +├── Cargo.toml # Rust 依赖配置 +├── pyproject.toml # Python 项目配置 +├── requirements.txt # Python 运行时依赖 +├── src/ +│ ├── lib.rs # Rust 库入口 +│ ├── core/ +│ │ └── error.rs # 错误类型定义 +│ ├── indicators/ +│ │ ├── mod.rs # 指标模块导出 +│ │ ├── trend.rs # 趋势指标(SMA, EMA, Supertrend) +│ │ ├── momentum.rs # 动量指标(RSI, MACD, Stochastic) +│ │ ├── volatility.rs # 波动率指标(ATR, Bollinger Bands) +│ │ ├── strength.rs # 强度指标(ADX) +│ │ ├── volume.rs # 成交量指标(VWAP, OBV, MFI) +│ │ ├── rolling.rs # 滚动统计(Rolling Min/Max) +│ │ ├── tick_features.rs # Tick 微结构函数 +│ │ └── ferro_bridge.rs # ferro-ta 指标桥接层 ★ +│ ├── portfolio/ # 回测引擎核心 +│ └── python/ +│ └── bindings.rs # PyO3 Python 绑定 ★ +├── python/ +│ └── raptorbt/ +│ └── __init__.py # Python 包导出 ★ +└── ferro-ta-main/ # ferro-ta 源码(编译时依赖) +``` + +标 ★ 的文件是添加新指标时需要修改的。 + +### 添加新指标 + +以添加一个 ferro-ta 中的指标为例,需要修改 3 个文件: + +#### 步骤 1:在 `ferro_bridge.rs` 中添加桥接函数 + +```rust +// src/indicators/ferro_bridge.rs + +pub fn my_indicator(data: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("MyIndicator period must be > 0")); + } + Ok(ferro_ta_core::category::my_indicator(data, period)) +} +``` + +**关键注意事项:** + +- 如果指标内部使用 EMA 处理中间结果(如 DEMA、TEMA、TRIX、PPO),必须使用 `ema_nan_safe` 而非 `ferro_ta_core::overlap::ema`,因为后者在输入含 NaN 时会全部输出 NaN +- 多输出指标需要定义 Result 结构体(参考 `PpoResult`、`AroonResult`) + +#### 步骤 2:在 `bindings.rs` 中添加 Python 绑定 + +```rust +// src/python/bindings.rs + +#[pyfunction] +pub fn my_indicator<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::my_indicator(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} +``` + +然后在 `#[pymodule]` 注册函数中添加: + +```rust +m.add_function(wrap_pyfunction!(my_indicator, m)?)?; +``` + +#### 步骤 3:在 `__init__.py` 中导出 + +```python +# python/raptorbt/__init__.py + +from raptorbt._raptorbt import ( + # ... 已有导出 ... + my_indicator, +) + +__all__ = [ + # ... 已有导出 ... + "my_indicator", +] +``` + +#### 步骤 4:在 `mod.rs` 中导出(如果需要在 Rust 内部使用) + +```rust +// src/indicators/mod.rs + +pub use ferro_bridge::{ /* ... */, my_indicator }; +``` + +### 编译与打包 + +#### 开发编译(直接安装到当前虚拟环境) + +```powershell +maturin develop --release +``` + +#### 产出 whl 文件 + +```powershell +maturin build --release +# 产出: target/wheels/raptorbt-0.4.1-cp312-cp312-win_amd64.whl +``` + +#### 安装 whl + +```powershell +pip uninstall raptorbt -y +pip install target\wheels\raptorbt-0.4.1-cp312-cp312-win_amd64.whl +``` + +### 常见编译问题 + +#### 1. 拒绝访问 (os error 5) + +**原因**:环境变量 `CARGO` 被错误设置为目录路径 `C:\Users\Administrator\.cargo\bin`,maturin 把目录当可执行文件调用。 + +**修复**: + +```powershell +# 临时修复(当前终端,推荐) +$env:CARGO = "C:\Users\Administrator\.cargo\bin\cargo.exe" + +# 永久修复:系统属性 → 环境变量 → 删除或修正小写 CARGO 变量 +# 确保值指向可执行文件而非目录 +``` + +> 如果不想设置环境变量,也可以在 PowerShell profile 中配置: +> `$HOME\.config\powershell\Microsoft.PowerShell_profile.ps1` 中添加 `$env:CARGO = "C:\Users\Administrator\.cargo\bin\cargo.exe"` + +#### 2. .pyd 文件被锁定 + +**原因**:Python 进程占用了旧的 `.pyd` 文件。 + +**修复**:退出虚拟环境,关闭所有 Python 进程,重新打开终端编译。 + +#### 3. 找不到 ferro_ta_core + +**原因**:`Cargo.toml` 中的本地依赖路径不正确。 + +**修复**:确认 `Cargo.toml` 中路径指向正确的 ferro-ta 源码位置: + +```toml +[dependencies] +ferro_ta_core = { path = "./ferro-ta-main/crates/ferro_ta_core", default-features = false } +``` + +#### 4. EMA 链式指标全输出 NaN + +**原因**:`ferro_ta_core::overlap::ema` 在输入含 NaN 时会传播 NaN,导致链式 EMA(DEMA、TEMA、TRIX、PPO)全部为 NaN。 + +**修复**:在 `ferro_bridge.rs` 中使用 `ema_nan_safe` 函数替代直接调用 `ferro_ta_core::overlap::ema` 处理中间结果。`ema_nan_safe` 会跳过 NaN 值计算初始种子,确保链式 EMA 正常工作。 + +--- + +## 移植与分发 + +### whl 文件分发 + +修改版 whl 已静态链接所有 Rust 依赖(包括 ferro-ta),目标电脑**不需要**安装 Rust 或 ferro-ta 源码。 + +**前提条件**: +- 相同操作系统 + 架构(如 Windows x64) +- 相同 Python 版本(如 3.12) + +**安装步骤**: + +```powershell +pip install raptorbt-0.4.1-cp312-cp312-win_amd64.whl +pip install numpy pandas requests +``` + +### 源码分发 + +如果目标电脑 Python 版本不同或需要进一步修改,需要携带源码: + +**必须保留的文件/目录**: +- `src/` — RaptorBT 修改后的源码 +- `python/` — Python 包代码 +- `Cargo.toml`、`pyproject.toml` — 项目配置 +- `ferro-ta-main/` — ferro-ta 源码(编译时需要) +- `requirements.txt` — Python 依赖 + +**在新电脑上编译**: + +```powershell +# 安装 Rust (https://rustup.rs) +# 安装 Python 3.10+ +pip install maturin numpy +maturin develop --release +``` + +### 关系图 + +``` +源码 (src/ + ferro-ta-main/) + │ + ├── maturin develop ──→ 直接安装到当前环境(开发用) + │ + └── maturin build ───→ .whl 文件(分发用) + │ + └── pip install xxx.whl → 可移植到同平台电脑 +``` + +--- + +## 附录:指标分类速查 + +### 原版指标 (12个) + +SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min, Rolling Max + +### ferro-ta 扩展指标 (68个) + +**趋势类**:WMA, DEMA, TEMA, KAMA, T3, TRIMA, Midpoint, Midprice, SAR, HullMA, Donchian, ChoppinessIndex, ChandelierExit, Ichimoku, PivotPoints + +**动量类**:CCI, WillR, ROC, MOM, CMO, TRIX, StochRSI, Aroon, AroonOsc, BOP, UltOSC, PPO, APO + +**波动率类**:NATR, TRange, StdDev, VAR + +**强度类**:ADXr, Plus_DI, Minus_DI, ADX_all + +**成交量类**:AD, ADOSC, OBV, MFI, VWMA + +**价格变换类**:TypPrice, MedPrice, AvgPrice, WclPrice + +**统计类**:LinearReg, LinearReg_Slope, LinearReg_Intercept, LinearReg_Angle, TSF, Beta, Correl + +**周期变换类 (Hilbert)**:HT_Trendline, HT_DCPeriod, HT_DCPhase, HT_Phasor, HT_Sine, HT_TrendMode + +**市场状态类**:Regime_ADX, Regime_Combined, DetectBreaks_CUSUM, RollingVarianceBreak + +**投资组合工具类**:RollingBeta, DrawdownSeries, ZScoreSeries, RelativeStrength, Spread, Ratio \ No newline at end of file diff --git a/backtest_output/atr_stop_rr_curves.csv b/backtest_output/atr_stop_rr_curves.csv new file mode 100644 index 0000000..bad746e --- /dev/null +++ b/backtest_output/atr_stop_rr_curves.csv @@ -0,0 +1,818 @@ +time,equity,drawdown,returns +2026-03-30 13:00:00,100000.0,0.0,0.0 +2026-03-30 14:00:00,100000.0,0.0,0.0 +2026-03-30 15:00:00,100000.0,0.0,0.0 +2026-03-30 16:00:00,100000.0,0.0,0.0 +2026-03-30 17:00:00,100000.0,0.0,0.0 +2026-03-30 18:00:00,100000.0,0.0,0.0 +2026-03-30 19:00:00,100000.0,0.0,0.0 +2026-03-30 20:00:00,100000.0,0.0,0.0 +2026-03-30 21:00:00,100000.0,0.0,0.0 +2026-03-30 22:00:00,100000.0,0.0,0.0 +2026-03-30 23:00:00,100000.0,0.0,0.0 +2026-03-31 01:00:00,100000.0,0.0,0.0 +2026-03-31 02:00:00,100000.0,0.0,0.0 +2026-03-31 03:00:00,100000.0,0.0,0.0 +2026-03-31 04:00:00,100000.0,0.0,0.0 +2026-03-31 05:00:00,100000.0,0.0,0.0 +2026-03-31 06:00:00,100000.0,0.0,0.0 +2026-03-31 07:00:00,100000.0,0.0,0.0 +2026-03-31 08:00:00,100000.0,0.0,0.0 +2026-03-31 09:00:00,100000.0,0.0,0.0 +2026-03-31 10:00:00,100000.0,0.0,0.0 +2026-03-31 11:00:00,100000.0,0.0,0.0 +2026-03-31 12:00:00,100000.0,0.0,0.0 +2026-03-31 13:00:00,100000.0,0.0,0.0 +2026-03-31 14:00:00,100000.0,0.0,0.0 +2026-03-31 15:00:00,100000.0,0.0,0.0 +2026-03-31 16:00:00,100000.0,0.0,0.0 +2026-03-31 17:00:00,100000.0,0.0,0.0 +2026-03-31 18:00:00,100000.0,0.0,0.0 +2026-03-31 19:00:00,100000.0,0.0,0.0 +2026-03-31 20:00:00,100000.0,0.0,0.0 +2026-03-31 21:00:00,100000.0,0.0,0.0 +2026-03-31 22:00:00,100000.0,0.0,0.0 +2026-03-31 23:00:00,100000.0,0.0,0.0 +2026-04-01 01:00:00,100000.0,0.0,0.0 +2026-04-01 02:00:00,100000.0,0.0,0.0 +2026-04-01 03:00:00,100000.0,0.0,0.0 +2026-04-01 04:00:00,100000.0,0.0,0.0 +2026-04-01 05:00:00,100000.0,0.0,0.0 +2026-04-01 06:00:00,100000.0,0.0,0.0 +2026-04-01 07:00:00,100000.0,0.0,0.0 +2026-04-01 08:00:00,100000.0,0.0,0.0 +2026-04-01 09:00:00,100000.0,0.0,0.0 +2026-04-01 10:00:00,100000.0,0.0,0.0 +2026-04-01 11:00:00,100000.0,0.0,0.0 +2026-04-01 12:00:00,100000.0,0.0,0.0 +2026-04-01 13:00:00,100000.0,0.0,0.0 +2026-04-01 14:00:00,100000.0,0.0,0.0 +2026-04-01 15:00:00,100000.0,0.0,0.0 +2026-04-01 16:00:00,100000.0,0.0,0.0 +2026-04-01 17:00:00,100000.0,0.0,0.0 +2026-04-01 18:00:00,100000.0,0.0,0.0 +2026-04-01 19:00:00,100000.0,0.0,0.0 +2026-04-01 20:00:00,100000.0,0.0,0.0 +2026-04-01 21:00:00,100000.0,0.0,0.0 +2026-04-01 22:00:00,100000.0,0.0,0.0 +2026-04-01 23:00:00,100000.0,0.0,0.0 +2026-04-02 01:00:00,100000.0,0.0,0.0 +2026-04-02 02:00:00,100000.0,0.0,0.0 +2026-04-02 03:00:00,100000.0,0.0,0.0 +2026-04-02 04:00:00,100000.0,0.0,0.0 +2026-04-02 05:00:00,100000.0,0.0,0.0 +2026-04-02 06:00:00,100000.0,0.0,0.0 +2026-04-02 07:00:00,100000.0,0.0,0.0 +2026-04-02 08:00:00,100000.0,0.0,0.0 +2026-04-02 09:00:00,100000.0,0.0,0.0 +2026-04-02 10:00:00,100000.0,0.0,0.0 +2026-04-02 11:00:00,100000.0,0.0,0.0 +2026-04-02 12:00:00,100000.0,0.0,0.0 +2026-04-02 13:00:00,100000.0,0.0,0.0 +2026-04-02 14:00:00,100000.0,0.0,0.0 +2026-04-02 15:00:00,100000.0,0.0,0.0 +2026-04-02 16:00:00,100000.0,0.0,0.0 +2026-04-02 17:00:00,100000.0,0.0,0.0 +2026-04-02 18:00:00,100000.0,0.0,0.0 +2026-04-02 19:00:00,100000.0,0.0,0.0 +2026-04-02 20:00:00,100000.0,0.0,0.0 +2026-04-02 21:00:00,100000.0,0.0,0.0 +2026-04-02 22:00:00,100000.0,0.0,0.0 +2026-04-02 23:00:00,99900.0999000999,0.09990009990010003,-0.0009990009990010003 +2026-04-06 01:00:00,99255.75404057198,0.7442459594280226,-0.006449902053874505 +2026-04-06 02:00:00,98295.05134913712,1.704948650862876,-0.009679062949258889 +2026-04-06 03:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 04:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 05:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 06:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 07:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 08:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 09:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 10:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 11:00:00,98295.05134913712,1.704948650862876,0.0 +2026-04-06 12:00:00,98196.85449464248,1.8031455053575192,-0.0009990009990010049 +2026-04-06 13:00:00,98115.68001269686,1.8843199873031409,-0.0008266505313574017 +2026-04-06 14:00:00,97870.273653619,2.1297263463809943,-0.0025011940909556553 +2026-04-06 15:00:00,97826.54822391119,2.1734517760888123,-0.00044676925970974955 +2026-04-06 16:00:00,97908.35034360382,2.0916496563961844,0.000836195502936421 +2026-04-06 17:00:00,97974.0430944567,2.025956905543295,0.0006709616761220544 +2026-04-06 18:00:00,97663.36240969063,2.336637590309372,-0.0031710509738436567 +2026-04-06 19:00:00,97503.73320936975,2.4962667906302523,-0.0016344839700608226 +2026-04-06 20:00:00,97360.42259047092,2.6395774095290836,-0.0014697962240184223 +2026-04-06 21:00:00,97460.63541740892,2.539364582591079,0.001029297370241826 +2026-04-06 22:00:00,97397.24400496171,2.6027559950382853,-0.0006504309373288097 +2026-04-06 23:00:00,97294.31141445335,2.7056885855466533,-0.0010568326810471574 +2026-04-07 01:00:00,97364.01079547052,2.6359892045294835,0.0007163767336845118 +2026-04-07 02:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 03:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 04:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 05:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 06:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 07:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 08:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 09:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 10:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 11:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 12:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 13:00:00,97364.01079547052,2.6359892045294835,0.0 +2026-04-07 14:00:00,97266.7440514191,2.7332559485809034,-0.0009990009990010049 +2026-04-07 15:00:00,97096.41594116145,2.9035840588385473,-0.0017511443599633766 +2026-04-07 16:00:00,96943.41287153053,3.0565871284694732,-0.0015757849365278611 +2026-04-07 17:00:00,96714.43010565724,3.285569894342756,-0.0023620250111962846 +2026-04-07 18:00:00,97179.70137742715,2.820298622572853,0.004810774061963764 +2026-04-07 19:00:00,97211.01169181275,2.788988308187254,0.0003221898600407873 +2026-04-07 20:00:00,97603.43429877883,2.396565701221174,0.004036812292522724 +2026-04-07 21:00:00,97318.09296701147,2.681907032988529,-0.0029234763491403594 +2026-04-07 22:00:00,98123.39425300891,1.8766057469910884,0.00827493903184497 +2026-04-07 23:00:00,98225.2571424767,1.7747428575233062,0.001038110129019091 +2026-04-08 01:00:00,99454.99538449403,0.5450046155059681,0.012519572641419414 +2026-04-08 02:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 03:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 04:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 05:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 06:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 07:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 08:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 09:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 10:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 11:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 12:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 13:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 14:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 15:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 16:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 17:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 18:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 19:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 20:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 21:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 22:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-08 23:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 01:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 02:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 03:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 04:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 05:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 06:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 07:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 08:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 09:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 10:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 11:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 12:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 13:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 14:00:00,99454.99538449403,0.5450046155059681,0.0 +2026-04-09 15:00:00,99355.63974474928,0.6443602552507218,-0.0009990009990010437 +2026-04-09 16:00:00,99750.49386768365,0.2495061323163536,0.003974149066412059 +2026-04-09 17:00:00,99837.16916296193,0.16283083703806916,0.0008689209638726894 +2026-04-09 18:00:00,100345.70610071781,0.0,0.005093663432361634 +2026-04-09 19:00:00,100261.96185407213,0.0834557350781211,-0.0008345573507812109 +2026-04-09 20:00:00,100447.66472100893,0.0,0.0018521766730146892 +2026-04-09 21:00:00,99910.65473939352,0.5346166912958539,-0.005346166912958539 +2026-04-09 22:00:00,99841.14701467758,0.603814641202396,-0.0006956988210840856 +2026-04-09 23:00:00,99771.22056872846,0.6734294462288178,-0.0007003770293108144 +2026-04-10 01:00:00,99744.84113103506,0.6996913187837079,-0.00026439926807573697 +2026-04-10 02:00:00,99734.79182143758,0.7096958416617593,-0.00010075016896643126 +2026-04-10 03:00:00,99720.55529950783,0.723868915738983,-0.00014274378749632872 +2026-04-10 04:00:00,99748.60962213411,0.6959396227044441,0.00028132938632382555 +2026-04-10 05:00:00,99508.26363426102,0.9352144615378298,-0.0024095171730570283 +2026-04-10 06:00:00,99794.25023655602,0.650502414633283,0.0028739985188178364 +2026-04-10 07:00:00,99627.1804644979,0.816827607480877,-0.0016741422643298033 +2026-04-10 08:00:00,99680.02454965563,0.7642190323542121,0.0005304183548239975 +2026-04-10 09:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 10:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 11:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 12:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 13:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 14:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 15:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 16:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 17:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 18:00:00,99680.02454965563,0.7642190323542121,0.0 +2026-04-10 19:00:00,99580.44410555008,0.8633556766775361,-0.000999000999001014 +2026-04-10 20:00:00,99504.98363034577,0.9384798474722464,-0.0007577840798171635 +2026-04-10 21:00:00,99611.79887364051,0.8321406472614541,0.0010734662666902232 +2026-04-10 22:00:00,99493.27785025869,0.950133458454239,-0.0011898291640347525 +2026-04-10 23:00:00,99272.95834647659,1.1694710651512499,-0.002214415974048903 +2026-04-13 01:00:00,98083.21295900168,2.3539141189339285,-0.011984586812881365 +2026-04-13 02:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 03:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 04:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 05:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 06:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 07:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 08:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 09:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 10:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 11:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 12:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 13:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 14:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 15:00:00,98083.21295900168,2.3539141189339285,0.0 +2026-04-13 16:00:00,97985.22773127041,2.451462656277655,-0.0009990009990010437 +2026-04-13 17:00:00,97748.47889380723,2.6871563761075223,-0.00241616867098044 +2026-04-13 18:00:00,97653.8207846903,2.7813926227936414,-0.0009683844719442739 +2026-04-13 19:00:00,98036.1815493071,2.400735923925821,0.0039154716276780235 +2026-04-13 20:00:00,97973.00710011527,2.463628823792932,-0.0006443993247540217 +2026-04-13 21:00:00,98148.23852311951,2.2891783539976007,0.0017885683841997043 +2026-04-13 22:00:00,98205.8204800878,2.23185302231545,0.0005866835496464839 +2026-04-13 23:00:00,98185.10754592654,2.2524736452227017,-0.0002109135085884208 +2026-04-14 01:00:00,98554.41916202176,1.8848079387864454,0.003761381184233793 +2026-04-14 02:00:00,98546.13398835727,1.8930561879493375,-8.406699298663158e-05 +2026-04-14 03:00:00,98798.00326775816,1.6423094133971787,0.0025558514495419345 +2026-04-14 04:00:00,98697.33840773445,1.7425256407264091,-0.001018895693173999 +2026-04-14 05:00:00,98909.23172420412,1.5315766683852468,0.0021469000065057423 +2026-04-14 06:00:00,98762.58415034242,1.677570678568571,-0.001482647992561596 +2026-04-14 07:00:00,98888.31166070123,1.5524034975215812,0.0012730277507464571 +2026-04-14 08:00:00,98594.18799561138,1.8452163428045172,-0.002974301615129469 +2026-04-14 09:00:00,99053.8080046497,1.3876447204926348,0.004661735325197662 +2026-04-14 10:00:00,98951.90036857632,1.489098185196304,-0.0010288108869939173 +2026-04-14 11:00:00,99147.63759640019,1.2942332987228038,0.0019781047872228195 +2026-04-14 12:00:00,98973.44182010402,1.4676527373727646,-0.0017569332010235709 +2026-04-14 13:00:00,98769.212289274,1.6709720792382687,-0.0020634781116456976 +2026-04-14 14:00:00,98905.08913737186,1.5357007929667,0.0013757004328424443 +2026-04-14 15:00:00,98828.65841031683,1.6117908914944448,-0.0007727683956573532 +2026-04-14 16:00:00,99311.89116429897,1.1307117590683107,0.004889601475473399 +2026-04-14 17:00:00,99686.8486918799,0.7574253032583435,0.0037755552047701226 +2026-04-14 18:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-14 19:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-14 20:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-14 21:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-14 22:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-14 23:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 01:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 02:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 03:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 04:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 05:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 06:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 07:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 08:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 09:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 10:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 11:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 12:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 13:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 14:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 15:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 16:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 17:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 18:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 19:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 20:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 21:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 22:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-15 23:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-16 01:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-16 02:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-16 03:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-16 04:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-16 05:00:00,99686.8486918799,0.7574253032583435,0.0 +2026-04-16 06:00:00,99587.26143044945,0.8565687345238238,-0.0009990009990010398 +2026-04-16 07:00:00,99771.60544627254,0.6730462839670026,0.001851080280501927 +2026-04-16 08:00:00,99526.36391122457,0.9171948520090015,-0.002458029355657057 +2026-04-16 09:00:00,99626.27712920708,0.8177269168807764,0.0010038869507141906 +2026-04-16 10:00:00,99522.23526585341,0.9213050972622212,-0.0010443215018337165 +2026-04-16 11:00:00,99372.98473568531,1.0698904631665795,-0.0014996701970107639 +2026-04-16 12:00:00,99353.78653470933,1.0890031035941132,-0.00019319336162685615 +2026-04-16 13:00:00,99444.41030060669,0.9987832202856657,0.0009121319786407491 +2026-04-16 14:00:00,99546.8007058119,0.8968491380055088,0.0010296245399384176 +2026-04-16 15:00:00,99422.94134467657,1.020156495602467,-0.001244232464098635 +2026-04-16 16:00:00,98788.58843727455,1.6516822848419872,-0.006380347421053081 +2026-04-16 17:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-16 18:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-16 19:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-16 20:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-16 21:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-16 22:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-16 23:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 01:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 02:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 03:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 04:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 05:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 06:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 07:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 08:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 09:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 10:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 11:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 12:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 13:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 14:00:00,98788.58843727455,1.6516822848419872,0.0 +2026-04-17 15:00:00,98689.89853873581,1.7499323524895,-0.0009990009990010203 +2026-04-17 16:00:00,99384.09761622603,1.058827109357821,0.007034145214140119 +2026-04-17 17:00:00,99237.07158690719,1.2051978883373113,-0.0014793717792417047 +2026-04-17 18:00:00,99120.79476870628,1.3209564960897748,-0.0011717074712253312 +2026-04-17 19:00:00,99112.03837083826,1.3296738693531027,-8.834067451193304e-05 +2026-04-17 20:00:00,99022.64165772057,1.4186721684832893,-0.0009019763349352496 +2026-04-17 21:00:00,98930.39402436677,1.5105086821643272,-0.0009315812203097305 +2026-04-17 22:00:00,98855.25191242958,1.5853159085402722,-0.0007595452608698559 +2026-04-17 23:00:00,98406.0283380842,2.0325374299097203,-0.004544256027422001 +2026-04-20 01:00:00,97131.77654933966,3.3011102656085365,-0.012948920002814437 +2026-04-20 02:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 03:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 04:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 05:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 06:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 07:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 08:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 09:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 10:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 11:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 12:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 13:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 14:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 15:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 16:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 17:00:00,97131.77654933966,3.3011102656085365,0.0 +2026-04-20 18:00:00,97034.74180753213,3.397712553055474,-0.0009990009990009285 +2026-04-20 19:00:00,97153.21048463654,3.2797718548486507,0.0012208892907592732 +2026-04-20 20:00:00,97178.68327186375,3.254412592094305,0.00026219192448852 +2026-04-20 21:00:00,97325.05071593131,3.108697463251738,0.0015061682165222919 +2026-04-20 22:00:00,97326.87020073325,3.106886087340723,1.8694927858239823e-05 +2026-04-20 23:00:00,97456.8622815833,2.9774733416974106,0.0013356237653788967 +2026-04-21 01:00:00,97600.39941595893,2.834575908716452,0.0014728273721856331 +2026-04-21 02:00:00,97481.93073885453,2.9525166069232758,-0.0012138134455732148 +2026-04-21 03:00:00,97526.60919899117,2.908037265108081,0.00045832555631594604 +2026-04-21 04:00:00,97248.02585931565,3.185379043485187,-0.002856485444983568 +2026-04-21 05:00:00,97179.49193177573,3.2536075361338392,-0.0007047333550920759 +2026-04-21 06:00:00,96982.78540818772,3.449437398514774,-0.002024156740046667 +2026-04-21 07:00:00,96818.82961103471,3.612662494497229,-0.0016905659748060744 +2026-04-21 08:00:00,96718.55578194976,3.7124894335937824,-0.0010356852018124738 +2026-04-21 09:00:00,96514.59497245638,3.9155412517319896,-0.0021088074345651433 +2026-04-21 10:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 11:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 12:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 13:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 14:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 15:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 16:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 17:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 18:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 19:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 20:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 21:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 22:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-21 23:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 01:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 02:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 03:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 04:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 05:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 06:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 07:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 08:00:00,96514.59497245638,3.9155412517319896,0.0 +2026-04-22 09:00:00,96418.17679566072,4.011529722009981,-0.0009990009990010077 +2026-04-22 10:00:00,96289.94203838629,4.1391929759348995,-0.001329985294642121 +2026-04-22 11:00:00,96219.14997995403,4.209669535672629,-0.0007351968121866246 +2026-04-22 12:00:00,96180.51774235243,4.248129601129505,-0.00040150258664363355 +2026-04-22 13:00:00,96262.23200408567,4.16677951503223,0.0008495926581736478 +2026-04-22 14:00:00,96084.24054288457,4.343977722372812,-0.0018490269495678299 +2026-04-22 15:00:00,95960.05104609196,4.467613744312733,-0.001292506409905763 +2026-04-22 16:00:00,96135.0085619317,4.2934359609609,0.0018232328342103353 +2026-04-22 17:00:00,95791.36368399911,4.635549318087771,-0.0035746070351801752 +2026-04-22 18:00:00,95511.47348675047,4.914192129770873,-0.002921872979822577 +2026-04-22 19:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-22 20:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-22 21:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-22 22:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-22 23:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 01:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 02:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 03:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 04:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 05:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 06:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 07:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 08:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 09:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 10:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 11:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 12:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 13:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 14:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 15:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 16:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 17:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 18:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 19:00:00,95511.47348675047,4.914192129770873,0.0 +2026-04-23 20:00:00,95416.05742932115,5.009182946824048,-0.0009990009990009875 +2026-04-23 21:00:00,95244.85721756196,5.179620171257982,-0.0017942494834897835 +2026-04-23 22:00:00,95144.77093991815,5.279260394773185,-0.0010508313054131031 +2026-04-23 23:00:00,95109.5178785618,5.314356343946575,-0.00037052021890519486 +2026-04-24 01:00:00,95088.64968706926,5.335131532250356,-0.00021941223084723328 +2026-04-24 02:00:00,95035.66048637849,5.387884576174226,-0.000557261049191043 +2026-04-24 03:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 04:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 05:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 06:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 07:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 08:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 09:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 10:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 11:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 12:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 13:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 14:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 15:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 16:00:00,95035.66048637849,5.387884576174226,0.0 +2026-04-24 17:00:00,94940.71976661187,5.48240217400023,-0.0009990009990010326 +2026-04-24 18:00:00,94957.99479062567,5.465204139519509,0.00018195589896794406 +2026-04-24 19:00:00,94951.36600234131,5.471803385308619,-6.980758491137788e-05 +2026-04-24 20:00:00,94882.26590628612,5.5405955232315005,-0.0007277419900782281 +2026-04-24 21:00:00,94867.40135073938,5.555393831970707,-0.00015666315938764474 +2026-04-24 22:00:00,94736.63343640238,5.68557895344686,-0.0013784283376070889 +2026-04-24 23:00:00,94555.84830137428,5.865558384059034,-0.0019082917396411477 +2026-04-27 01:00:00,93963.5488981921,6.455218088769228,-0.006264016597835109 +2026-04-27 02:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 03:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 04:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 05:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 06:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 07:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 08:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 09:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 10:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 11:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 12:00:00,93963.5488981921,6.455218088769228,0.0 +2026-04-27 13:00:00,93869.67921897312,6.548669419349879,-0.0009990009990009981 +2026-04-27 14:00:00,93790.14013110432,6.62785402567171,-0.0008473352474472751 +2026-04-27 15:00:00,93678.70566965402,6.738791857586268,-0.0011881255459744828 +2026-04-27 16:00:00,93617.90511376433,6.799321443872391,-0.0006490328346775396 +2026-04-27 17:00:00,93160.66931740158,7.254519479220158,-0.0048840635325807765 +2026-04-27 18:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-27 19:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-27 20:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-27 21:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-27 22:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-27 23:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 01:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 02:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 03:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 04:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 05:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 06:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 07:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 08:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 09:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 10:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 11:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 12:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 13:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 14:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 15:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 16:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 17:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 18:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 19:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 20:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 21:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 22:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-28 23:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-29 01:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-29 02:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-29 03:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-29 04:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-29 05:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-29 06:00:00,93160.66931740158,7.254519479220158,0.0 +2026-04-29 07:00:00,93067.6017156859,7.347172306913238,-0.000999000999000938 +2026-04-29 08:00:00,92787.07794268559,7.626445870693405,-0.0030141936380534595 +2026-04-29 09:00:00,92657.7298052791,7.7552175427534245,-0.001394031801350452 +2026-04-29 10:00:00,92319.91113484812,8.091530658015248,-0.0036458768323042855 +2026-04-29 11:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 12:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 13:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 14:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 15:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 16:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 17:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 18:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 19:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 20:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 21:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 22:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-29 23:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-30 01:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-30 02:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-30 03:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-30 04:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-30 05:00:00,92319.91113484812,8.091530658015248,0.0 +2026-04-30 06:00:00,92227.68345139673,8.183347310704537,-0.0009990009990009313 +2026-04-30 07:00:00,91946.30461334008,8.463472128775896,-0.0030509151648044942 +2026-04-30 08:00:00,92361.69265340644,8.049935346989987,0.004517724141423463 +2026-04-30 09:00:00,92907.64857156381,7.506412588473149,0.005911064451862183 +2026-04-30 10:00:00,93089.22901597877,7.3256413929264035,0.0019544186857241228 +2026-04-30 11:00:00,93451.37775071498,6.9651066450633525,0.0038903398230320484 +2026-04-30 12:00:00,93661.37721810899,6.756043081487954,0.0022471521816852234 +2026-04-30 13:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 14:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 15:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 16:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 17:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 18:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 19:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 20:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 21:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 22:00:00,93661.37721810899,6.756043081487954,0.0 +2026-04-30 23:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 01:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 02:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 03:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 04:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 05:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 06:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 07:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 08:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 09:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 10:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 11:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 12:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 13:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 14:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 15:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 16:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 17:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 18:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 19:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 20:00:00,93661.37721810899,6.756043081487954,0.0 +2026-05-01 21:00:00,93567.80940870028,6.849193887600359,-0.0009990009990010517 +2026-05-01 22:00:00,93296.67461076834,7.119120320121225,-0.0028977358735378144 +2026-05-01 23:00:00,93390.35775363588,7.0258546945561715,0.0010041423583249877 +2026-05-04 01:00:00,93458.54613192174,6.957970211153541,0.0007301436671411541 +2026-05-04 02:00:00,93358.9951464124,7.057077528168667,-0.0010651886812878405 +2026-05-04 03:00:00,93341.18927908555,7.07480404015509,-0.00019072471055337376 +2026-05-04 04:00:00,93341.39161848697,7.0746026025188975,2.1677396976375997e-06 +2026-05-04 05:00:00,93194.89789184317,7.220443451134631,-0.0015694401390817945 +2026-05-04 06:00:00,93381.65715937389,7.034516512913178,0.0020039645061628443 +2026-05-04 07:00:00,93196.31426765324,7.21903338768118,-0.001984789061992377 +2026-05-04 08:00:00,93057.50943826423,7.357219606120946,-0.0014893811035314045 +2026-05-04 09:00:00,92663.29041405975,7.749681715916538,-0.004236294594430397 +2026-05-04 10:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 11:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 12:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 13:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 14:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 15:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 16:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 17:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 18:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 19:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 20:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 21:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 22:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-04 23:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 01:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 02:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 03:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 04:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 05:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 06:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 07:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 08:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 09:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 10:00:00,92663.29041405975,7.749681715916538,0.0 +2026-05-05 11:00:00,92570.71969436538,7.841839876040502,-0.000999000999001043 +2026-05-05 12:00:00,92528.68261772925,7.8836896061988755,-0.0004541077003065468 +2026-05-05 13:00:00,92274.0232259336,8.137214058462607,-0.0027522210907048408 +2026-05-05 14:00:00,92655.80923596217,7.7571295526764725,0.004137524263938897 +2026-05-05 15:00:00,92698.0493902632,7.715077649908643,0.00045588241740421943 +2026-05-05 16:00:00,92998.40125666332,7.416064360516264,0.0032401098877023944 +2026-05-05 17:00:00,93058.71532314124,7.356019095506429,0.0006485494983022494 +2026-05-05 18:00:00,92779.28045622673,7.634208606124355,-0.0030027801903796864 +2026-05-05 19:00:00,92629.81529485385,7.783007646687433,-0.0016109756471262573 +2026-05-05 20:00:00,92603.41519841569,7.809290085917336,-0.0002850064674545716 +2026-05-05 21:00:00,92625.34758622585,7.787455444095574,0.00023684210526321958 +2026-05-05 22:00:00,92496.59634667366,7.915632878493671,-0.0013900216615363942 +2026-05-05 23:00:00,92547.77191823069,7.86468538090956,0.0005532697804925542 +2026-05-06 01:00:00,92870.25925010587,7.543635277085962,0.0034845499269297066 +2026-05-06 02:00:00,93295.0977250951,7.120690178094156,0.004574537407558105 +2026-05-06 03:00:00,93797.71494574439,6.62031296967891,0.005387391544733812 +2026-05-06 04:00:00,93865.84371826565,6.5524878263961,0.0007263372307168508 +2026-05-06 05:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 06:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 07:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 08:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 09:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 10:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 11:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 12:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 13:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 14:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 15:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 16:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 17:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 18:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 19:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 20:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 21:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 22:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-06 23:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 01:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 02:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 03:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 04:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 05:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 06:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 07:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 08:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 09:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 10:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 11:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 12:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 13:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 14:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 15:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 16:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 17:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 18:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 19:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 20:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 21:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 22:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-07 23:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 01:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 02:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 03:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 04:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 05:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 06:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 07:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 08:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 09:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 10:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 11:00:00,93865.84371826565,6.5524878263961,0.0 +2026-05-08 12:00:00,93772.07164661904,6.645841984411682,-0.0009990009990009428 +2026-05-08 13:00:00,93929.0894577405,6.489523953965105,0.0016744624317695132 +2026-05-08 14:00:00,93887.49665352708,6.5309313916879645,-0.00044281068254293295 +2026-05-08 15:00:00,94021.23045463431,6.3977936014977494,0.0014244048022788978 +2026-05-08 16:00:00,94489.29875850966,5.931811335831944,0.004978325657003541 +2026-05-08 17:00:00,93852.27212555686,6.565998934639862,-0.006741786015164226 +2026-05-08 18:00:00,93674.55741664501,6.7429216230920535,-0.0018935578743805206 +2026-05-08 19:00:00,93782.22108688165,6.635737777120451,0.0011493373783211167 +2026-05-08 20:00:00,93950.1843727961,6.468523052536298,0.0017909928339065804 +2026-05-08 21:00:00,93970.8812705865,6.447918394530672,0.00022029651063033335 +2026-05-08 22:00:00,94011.2800230043,6.407699687077377,0.0004299071358230771 +2026-05-08 23:00:00,93844.90880615066,6.573329437968785,-0.0017696941985358928 +2026-05-11 01:00:00,93371.2682605625,7.0448591115591945,-0.0050470563785887405 +2026-05-11 02:00:00,93274.71603831847,7.140981029885723,-0.0010340678031125674 +2026-05-11 03:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 04:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 05:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 06:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 07:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 08:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 09:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 10:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 11:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 12:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 13:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 14:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 15:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 16:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 17:00:00,93274.71603831847,7.140981029885723,0.0 +2026-05-11 18:00:00,93181.53450381465,7.233747282603124,-0.0009990009990010502 +2026-05-11 19:00:00,92872.97985619011,7.54092679591639,-0.003311328250469032 +2026-05-11 20:00:00,93013.96589811683,7.400569085940448,0.0015180523134396202 +2026-05-11 21:00:00,93039.17010672943,7.375477204827427,0.0002709723036668451 +2026-05-11 22:00:00,93209.1016069847,7.2063030376357435,0.0018264511609500074 +2026-05-11 23:00:00,93228.79239496328,7.1867000055162045,0.000211253918760131 +2026-05-12 01:00:00,93550.3429626537,6.8665824910040305,0.0034490478684757256 +2026-05-12 02:00:00,93875.83168793983,6.542544370067945,0.00347928949246157 +2026-05-12 03:00:00,93517.85316248902,6.898927494001283,-0.00381331934976408 +2026-05-12 04:00:00,93083.86819544084,7.33097832191606,-0.004640664347738192 +2026-05-12 05:00:00,93138.60858602134,7.276481892623714,0.0005880760183446898 +2026-05-12 06:00:00,92945.04814019175,7.469179698358866,-0.0020781977395637847 +2026-05-12 07:00:00,92825.91887292128,7.587778042682103,-0.001281717204458132 +2026-05-12 08:00:00,92829.06939899786,7.584641557542966,3.3940155021760975e-05 +2026-05-12 09:00:00,92489.00949060755,7.9231859222475265,-0.003663291149980851 +2026-05-12 10:00:00,92222.78636712115,8.188222570163465,-0.002878429825907441 +2026-05-12 11:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 12:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 13:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 14:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 15:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 16:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 17:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 18:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 19:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 20:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 21:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 22:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-12 23:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-13 01:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-13 02:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-13 03:00:00,92222.78636712115,8.188222570163465,0.0 +2026-05-13 04:00:00,92130.65571140974,8.279942627535933,-0.0009990009990010547 +2026-05-13 05:00:00,91924.55387453566,8.485125931146346,-0.0022370603495938507 +2026-05-13 06:00:00,92003.11550627188,8.406914424732117,0.0008546316345841658 +2026-05-13 07:00:00,92072.6650805022,8.337674811821747,0.0007559480333640519 +2026-05-13 08:00:00,92159.2591982763,8.251466617719274,0.0009404975700266474 +2026-05-13 09:00:00,92115.37439650841,8.295155838259912,-0.00047618440241003687 +2026-05-13 10:00:00,92137.12088309876,8.27350626915271,0.00023607879502002033 +2026-05-13 11:00:00,92094.0197385053,8.316415325040838,-0.00046779348193592946 +2026-05-13 12:00:00,92098.52576725827,8.311929378288886,4.892857066895046e-05 +2026-05-13 13:00:00,91966.67544766105,8.44319208107389,-0.001431622477111236 +2026-05-13 14:00:00,91937.73009035215,8.472008437720202,-0.00031473745427897614 +2026-05-13 15:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-13 16:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-13 17:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-13 18:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-13 19:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-13 20:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-13 21:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-13 22:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-13 23:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-14 01:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-14 02:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-14 03:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-14 04:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-14 05:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-14 06:00:00,91937.73009035215,8.472008437720202,0.0 +2026-05-14 07:00:00,91845.884206146,8.56344499272748,-0.0009990009990010623 +2026-05-14 08:00:00,91594.71397242317,8.813495837035761,-0.0027346923152167437 +2026-05-14 09:00:00,91801.3532471345,8.60777749078524,0.002256017468142775 +2026-05-14 10:00:00,91663.07290073032,8.745441563701469,-0.0015062996515086264 +2026-05-14 11:00:00,91666.979125205,8.741552748082372,4.261502861593572e-05 +2026-05-14 12:00:00,91788.85332881547,8.62022170076637,0.0013295322347647424 +2026-05-14 13:00:00,91740.61145655299,8.668248573662298,-0.0005255744081437551 +2026-05-14 14:00:00,91682.21340065631,8.726386367167871,-0.0006365562096163788 +2026-05-14 15:00:00,91894.12607840849,8.515418119831553,0.0023113826541916495 +2026-05-14 16:00:00,91397.84025889858,9.009492144238525,-0.00540062614107082 +2026-05-14 17:00:00,91397.05901400364,9.010269907362346,-8.547739123022577e-06 +2026-05-14 18:00:00,91519.51915128529,8.888355537703497,0.0013398695603858667 +2026-05-14 19:00:00,91286.54059556355,9.120295778792059,-0.0025456706709376126 +2026-05-14 20:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-14 21:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-14 22:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-14 23:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 01:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 02:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 03:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 04:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 05:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 06:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 07:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 08:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 09:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 10:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 11:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 12:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 13:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 14:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 15:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 16:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 17:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 18:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 19:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 20:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 21:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 22:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-15 23:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 01:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 02:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 03:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 04:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 05:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 06:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 07:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 08:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 09:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 10:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 11:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 12:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 13:00:00,91286.54059556355,9.120295778792059,0.0 +2026-05-18 14:00:00,91195.34525031324,9.211084694097956,-0.000999000999000937 +2026-05-18 15:00:00,91611.77241756879,8.796513416197007,0.004566320420329978 +2026-05-18 16:00:00,91697.3423888961,8.711324804231786,0.0009340499487039287 +2026-05-18 17:00:00,91113.58284211293,9.29248271208813,-0.006366155567599742 +2026-05-18 18:00:00,91052.26103596269,9.353531225580765,-0.0006730259554879241 +2026-05-18 19:00:00,91185.72614346615,9.220660931508563,0.001465807723882322 +2026-05-18 20:00:00,91121.99956060413,9.28410350435385,-0.0006988657716203432 +2026-05-18 21:00:00,90896.95253999393,9.508147558772901,-0.002469733123673692 +2026-05-18 22:00:00,91352.85812493444,9.054273806499237,0.00501563113174688 +2026-05-18 23:00:00,91504.15865971691,8.903647572228188,0.001656221139524135 +2026-05-19 01:00:00,91662.27272851607,8.74623816979129,0.0017279440750572611 +2026-05-19 02:00:00,91818.78361284068,8.590424806922856,0.0017074733111643887 +2026-05-19 03:00:00,91448.8487953461,8.958710937339191,-0.004028966655171878 +2026-05-19 04:00:00,91460.47188278634,8.947139650468042,0.00012709933031798362 +2026-05-19 05:00:00,91123.80314313798,9.282307959839347,-0.0036810299872476956 +2026-05-19 06:00:00,91082.7215409785,9.323206473780495,-0.00045083283118625765 +2026-05-19 07:00:00,91031.21923973467,9.374479244916467,-0.0005654453487170038 +2026-05-19 08:00:00,90998.15355994776,9.407397561015443,-0.00036323450419598963 +2026-05-19 09:00:00,91143.64255101011,9.262556970179968,0.0015988125623505184 +2026-05-19 10:00:00,91326.80637722358,9.080209449486283,0.0020096171393518187 +2026-05-19 11:00:00,90946.19395033258,9.459125602438304,-0.004167587173900284 +2026-05-19 12:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 13:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 14:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 15:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 16:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 17:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 18:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 19:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 20:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 21:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 22:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-19 23:00:00,90946.19395033258,9.459125602438304,0.0 +2026-05-20 01:00:00,90946.19395033258,9.459125602438304,0.0 diff --git a/backtest_output/atr_stop_rr_metrics.csv b/backtest_output/atr_stop_rr_metrics.csv new file mode 100644 index 0000000..4d1179b --- /dev/null +++ b/backtest_output/atr_stop_rr_metrics.csv @@ -0,0 +1,43 @@ +metric,value +Start Value,100000.0 +End Value,90946.19395033258 +Total Return [%],-9.053806049667415 +Total Fees Paid,4198.746641799065 +Max Drawdown [%],9.508147558772901 +Max Drawdown Duration,648.0 +Total Trades,22.0 +Total Closed Trades,22.0 +Total Open Trades,0.0 +Open Trade PnL,-0.0 +Win Rate [%],22.727272727272727 +Best Trade [%],2.1497425552953047 +Worst Trade [%],-1.7066535995137522 +Avg Winning Trade [%],1.3532841800373892 +Avg Losing Trade [%],-0.9482213240612305 +Avg Winning Trade Duration,14.4 +Avg Losing Trade Duration,9.470588235294118 +Profit Factor,0.41654128046637545 +Expectancy,-411.5366386212467 +SQN,-1.8217208364286983 +Sharpe Ratio,-1.4024998779001383 +Sortino Ratio,-1.8223785994388895 +Calmar Ratio,-0.4368857120897103 +Omega Ratio,0.6849595839574596 +total_trades,22.0 +total_closed_trades,22.0 +total_open_trades,0.0 +winning_trades,5.0 +losing_trades,17.0 +max_consecutive_wins,2.0 +max_consecutive_losses,8.0 +avg_holding_period,10.590909090909092 +avg_winning_duration,14.4 +avg_losing_duration,9.470588235294118 +start_value,100000.0 +end_value,90946.19395033258 +total_fees_paid,4198.746641799065 +open_trade_pnl,-0.0 +exposure_pct,28.518971848225217 +payoff_ratio,1.4271817619975842 +recovery_factor,-0.9522155597294789 +omega_ratio,0.6849595839574596 diff --git a/backtest_output/atr_stop_rr_trades.csv b/backtest_output/atr_stop_rr_trades.csv new file mode 100644 index 0000000..67f6c45 --- /dev/null +++ b/backtest_output/atr_stop_rr_trades.csv @@ -0,0 +1,23 @@ +trade_id,symbol,direction,entry_idx,exit_idx,entry_time,exit_time,entry_price,exit_price,size,pnl,return_pct,fees,exit_reason +0,XAUUSD,Long,79,81,2026-04-02 23:00:00,2026-04-06 02:00:00,4676.04,4605.517752778075,21.36425263686793,-1704.9486508628895,-1.7066535995137522,198.29354469403097,StopLoss +1,XAUUSD,Long,91,103,2026-04-06 12:00:00,2026-04-07 01:00:00,4693.64,4658.49,20.921258233405734,-931.0405536666037,-0.9481368404905478,195.65832676238077,Signal +2,XAUUSD,Long,116,126,2026-04-07 14:00:00,2026-04-08 01:00:00,4659.81,4769.413141707614,20.873542923728458,2090.984589023521,2.1497425552953047,196.8212939858476,TakeProfit +3,XAUUSD,Long,163,179,2026-04-09 15:00:00,2026-04-10 08:00:00,4745.67,4765.93,20.93606166141963,225.0291651616073,0.22648856747309057,199.13544409875897,Signal +4,XAUUSD,Long,190,195,2026-04-10 19:00:00,2026-04-13 01:00:00,4763.89,4696.96,20.903178726954252,-1596.8115906539595,-1.6035393344514735,197.76183845890515,StopLoss +5,XAUUSD,Long,210,234,2026-04-13 16:00:00,2026-04-14 17:00:00,4730.63,4817.600179208416,20.71293416125768,1603.6357328782265,1.6366096910814767,197.77186305847755,TakeProfit +6,XAUUSD,Long,269,279,2026-04-16 06:00:00,2026-04-16 16:00:00,4824.21,4790.3109626960495,20.643226855889246,-898.2602546053403,-0.9019830866949528,198.47473734363723,StopLoss +7,XAUUSD,Long,301,310,2026-04-17 15:00:00,2026-04-20 01:00:00,4846.36,4774.62,20.363715972139055,-1656.8118879348817,-1.6788059492072356,195.9189040936304,StopLoss +8,XAUUSD,Long,327,341,2026-04-20 18:00:00,2026-04-21 09:00:00,4799.78,4778.83,20.216497799385003,-617.1815768832794,-0.6360418602519246,193.6459479861672,Signal +9,XAUUSD,Long,364,373,2026-04-22 09:00:00,2026-04-22 18:00:00,4766.97,4726.868936941962,20.226302409216068,-1003.1214857059093,-1.040386283005358,192.02525736297852,StopLoss +10,XAUUSD,Long,398,403,2026-04-23 20:00:00,2026-04-24 02:00:00,4709.49,4695.41,20.26038008984437,-475.8130003719846,-0.4986718307077821,190.54684870697733,Signal +11,XAUUSD,Long,418,425,2026-04-24 17:00:00,2026-04-27 01:00:00,4726.42,4682.456101331006,20.087237225344317,-1072.1115881863957,-1.1292431643892262,188.9983262713087,StopLoss +12,XAUUSD,Long,437,441,2026-04-27 13:00:00,2026-04-27 17:00:00,4708.88,4677.991207842961,19.934608488424665,-802.8795807905194,-0.8553130121150342,187.12360245961537,StopLoss +13,XAUUSD,Long,477,480,2026-04-29 07:00:00,2026-04-29 10:00:00,4604.88,4572.457570645263,20.210646469763795,-840.7581825534693,-0.9033843862463744,185.47992517399234,StopLoss +14,XAUUSD,Long,499,505,2026-04-30 06:00:00,2026-04-30 12:00:00,4556.01,4631.465353514177,20.243081874578138,1341.466083260861,1.4545156433076876,185.9828158018562,TakeProfit +15,XAUUSD,Long,537,548,2026-05-01 21:00:00,2026-05-04 09:00:00,4624.3,4584.181123380027,20.233940144173236,-998.0868040492248,-1.0666989110428173,186.3238558692206,StopLoss +16,XAUUSD,Long,573,589,2026-05-05 11:00:00,2026-05-06 04:00:00,4558.39,4626.791605269877,20.30776649088064,1202.553304205902,1.2990644430293858,186.53052321615286,TakeProfit +17,XAUUSD,Long,643,656,2026-05-08 12:00:00,2026-05-11 02:00:00,4711.96,4691.66,19.900863260006247,-591.1276799471905,-0.6303877791831889,187.14015576905996,Signal +18,XAUUSD,Long,672,687,2026-05-11 18:00:00,2026-05-12 10:00:00,4732.24,4688.238051669294,19.69078797859252,-1051.9296711973193,-1.1289035717202698,185.49663597240442,StopLoss +19,XAUUSD,Long,704,714,2026-05-13 04:00:00,2026-05-13 14:00:00,4702.6,4697.45,19.591429360653628,-285.056276768989,-0.3094043720495161,184.16041556161213,Signal +20,XAUUSD,Long,730,742,2026-05-14 07:00:00,2026-05-14 19:00:00,4702.54,4678.58,19.53112237347179,-651.1894947886085,-0.7090023689325353,183.22380272022366,Signal +21,XAUUSD,Long,783,803,2026-05-18 14:00:00,2026-05-19 11:00:00,4550.71,4542.82,20.03980593145097,-340.34664523098206,-0.3732061590389264,182.23257643182734,Signal diff --git a/backtest_output/ferro_sar_adx_cci_curves.csv b/backtest_output/ferro_sar_adx_cci_curves.csv new file mode 100644 index 0000000..e2fbbf4 --- /dev/null +++ b/backtest_output/ferro_sar_adx_cci_curves.csv @@ -0,0 +1,818 @@ +time,equity,drawdown,returns +2026-03-30 13:00:00,100000.0,0.0,0.0 +2026-03-30 14:00:00,100000.0,0.0,0.0 +2026-03-30 15:00:00,100000.0,0.0,0.0 +2026-03-30 16:00:00,100000.0,0.0,0.0 +2026-03-30 17:00:00,100000.0,0.0,0.0 +2026-03-30 18:00:00,100000.0,0.0,0.0 +2026-03-30 19:00:00,100000.0,0.0,0.0 +2026-03-30 20:00:00,100000.0,0.0,0.0 +2026-03-30 21:00:00,100000.0,0.0,0.0 +2026-03-30 22:00:00,100000.0,0.0,0.0 +2026-03-30 23:00:00,100000.0,0.0,0.0 +2026-03-31 01:00:00,100000.0,0.0,0.0 +2026-03-31 02:00:00,100000.0,0.0,0.0 +2026-03-31 03:00:00,100000.0,0.0,0.0 +2026-03-31 04:00:00,100000.0,0.0,0.0 +2026-03-31 05:00:00,100000.0,0.0,0.0 +2026-03-31 06:00:00,100000.0,0.0,0.0 +2026-03-31 07:00:00,100000.0,0.0,0.0 +2026-03-31 08:00:00,100000.0,0.0,0.0 +2026-03-31 09:00:00,100000.0,0.0,0.0 +2026-03-31 10:00:00,100000.0,0.0,0.0 +2026-03-31 11:00:00,100000.0,0.0,0.0 +2026-03-31 12:00:00,100000.0,0.0,0.0 +2026-03-31 13:00:00,100000.0,0.0,0.0 +2026-03-31 14:00:00,100000.0,0.0,0.0 +2026-03-31 15:00:00,100000.0,0.0,0.0 +2026-03-31 16:00:00,100000.0,0.0,0.0 +2026-03-31 17:00:00,100000.0,0.0,0.0 +2026-03-31 18:00:00,100000.0,0.0,0.0 +2026-03-31 19:00:00,100000.0,0.0,0.0 +2026-03-31 20:00:00,100000.0,0.0,0.0 +2026-03-31 21:00:00,100000.0,0.0,0.0 +2026-03-31 22:00:00,100000.0,0.0,0.0 +2026-03-31 23:00:00,100000.0,0.0,0.0 +2026-04-01 01:00:00,100000.0,0.0,0.0 +2026-04-01 02:00:00,100000.0,0.0,0.0 +2026-04-01 03:00:00,100000.0,0.0,0.0 +2026-04-01 04:00:00,100000.0,0.0,0.0 +2026-04-01 05:00:00,100000.0,0.0,0.0 +2026-04-01 06:00:00,100000.0,0.0,0.0 +2026-04-01 07:00:00,100000.0,0.0,0.0 +2026-04-01 08:00:00,100000.0,0.0,0.0 +2026-04-01 09:00:00,100000.0,0.0,0.0 +2026-04-01 10:00:00,100000.0,0.0,0.0 +2026-04-01 11:00:00,100000.0,0.0,0.0 +2026-04-01 12:00:00,100000.0,0.0,0.0 +2026-04-01 13:00:00,100000.0,0.0,0.0 +2026-04-01 14:00:00,100000.0,0.0,0.0 +2026-04-01 15:00:00,100000.0,0.0,0.0 +2026-04-01 16:00:00,100000.0,0.0,0.0 +2026-04-01 17:00:00,100000.0,0.0,0.0 +2026-04-01 18:00:00,100000.0,0.0,0.0 +2026-04-01 19:00:00,100000.0,0.0,0.0 +2026-04-01 20:00:00,100000.0,0.0,0.0 +2026-04-01 21:00:00,100000.0,0.0,0.0 +2026-04-01 22:00:00,100000.0,0.0,0.0 +2026-04-01 23:00:00,100000.0,0.0,0.0 +2026-04-02 01:00:00,100000.0,0.0,0.0 +2026-04-02 02:00:00,100000.0,0.0,0.0 +2026-04-02 03:00:00,100000.0,0.0,0.0 +2026-04-02 04:00:00,100000.0,0.0,0.0 +2026-04-02 05:00:00,100000.0,0.0,0.0 +2026-04-02 06:00:00,100000.0,0.0,0.0 +2026-04-02 07:00:00,100000.0,0.0,0.0 +2026-04-02 08:00:00,100000.0,0.0,0.0 +2026-04-02 09:00:00,100000.0,0.0,0.0 +2026-04-02 10:00:00,100000.0,0.0,0.0 +2026-04-02 11:00:00,100000.0,0.0,0.0 +2026-04-02 12:00:00,100000.0,0.0,0.0 +2026-04-02 13:00:00,100000.0,0.0,0.0 +2026-04-02 14:00:00,100000.0,0.0,0.0 +2026-04-02 15:00:00,100000.0,0.0,0.0 +2026-04-02 16:00:00,100000.0,0.0,0.0 +2026-04-02 17:00:00,100000.0,0.0,0.0 +2026-04-02 18:00:00,100000.0,0.0,0.0 +2026-04-02 19:00:00,100000.0,0.0,0.0 +2026-04-02 20:00:00,100000.0,0.0,0.0 +2026-04-02 21:00:00,100000.0,0.0,0.0 +2026-04-02 22:00:00,100000.0,0.0,0.0 +2026-04-02 23:00:00,100000.0,0.0,0.0 +2026-04-06 01:00:00,100000.0,0.0,0.0 +2026-04-06 02:00:00,100000.0,0.0,0.0 +2026-04-06 03:00:00,100000.0,0.0,0.0 +2026-04-06 04:00:00,100000.0,0.0,0.0 +2026-04-06 05:00:00,100000.0,0.0,0.0 +2026-04-06 06:00:00,100000.0,0.0,0.0 +2026-04-06 07:00:00,100000.0,0.0,0.0 +2026-04-06 08:00:00,100000.0,0.0,0.0 +2026-04-06 09:00:00,100000.0,0.0,0.0 +2026-04-06 10:00:00,100000.0,0.0,0.0 +2026-04-06 11:00:00,100000.0,0.0,0.0 +2026-04-06 12:00:00,100000.0,0.0,0.0 +2026-04-06 13:00:00,100000.0,0.0,0.0 +2026-04-06 14:00:00,100000.0,0.0,0.0 +2026-04-06 15:00:00,100000.0,0.0,0.0 +2026-04-06 16:00:00,100000.0,0.0,0.0 +2026-04-06 17:00:00,100000.0,0.0,0.0 +2026-04-06 18:00:00,100000.0,0.0,0.0 +2026-04-06 19:00:00,100000.0,0.0,0.0 +2026-04-06 20:00:00,100000.0,0.0,0.0 +2026-04-06 21:00:00,100000.0,0.0,0.0 +2026-04-06 22:00:00,100000.0,0.0,0.0 +2026-04-06 23:00:00,100000.0,0.0,0.0 +2026-04-07 01:00:00,100000.0,0.0,0.0 +2026-04-07 02:00:00,100000.0,0.0,0.0 +2026-04-07 03:00:00,100000.0,0.0,0.0 +2026-04-07 04:00:00,100000.0,0.0,0.0 +2026-04-07 05:00:00,100000.0,0.0,0.0 +2026-04-07 06:00:00,100000.0,0.0,0.0 +2026-04-07 07:00:00,100000.0,0.0,0.0 +2026-04-07 08:00:00,100000.0,0.0,0.0 +2026-04-07 09:00:00,100000.0,0.0,0.0 +2026-04-07 10:00:00,100000.0,0.0,0.0 +2026-04-07 11:00:00,100000.0,0.0,0.0 +2026-04-07 12:00:00,100000.0,0.0,0.0 +2026-04-07 13:00:00,100000.0,0.0,0.0 +2026-04-07 14:00:00,100000.0,0.0,0.0 +2026-04-07 15:00:00,100000.0,0.0,0.0 +2026-04-07 16:00:00,100000.0,0.0,0.0 +2026-04-07 17:00:00,100000.0,0.0,0.0 +2026-04-07 18:00:00,100000.0,0.0,0.0 +2026-04-07 19:00:00,100000.0,0.0,0.0 +2026-04-07 20:00:00,100000.0,0.0,0.0 +2026-04-07 21:00:00,100000.0,0.0,0.0 +2026-04-07 22:00:00,100000.0,0.0,0.0 +2026-04-07 23:00:00,100000.0,0.0,0.0 +2026-04-08 01:00:00,100000.0,0.0,0.0 +2026-04-08 02:00:00,100000.0,0.0,0.0 +2026-04-08 03:00:00,100000.0,0.0,0.0 +2026-04-08 04:00:00,100000.0,0.0,0.0 +2026-04-08 05:00:00,100000.0,0.0,0.0 +2026-04-08 06:00:00,100000.0,0.0,0.0 +2026-04-08 07:00:00,100000.0,0.0,0.0 +2026-04-08 08:00:00,100000.0,0.0,0.0 +2026-04-08 09:00:00,100000.0,0.0,0.0 +2026-04-08 10:00:00,100000.0,0.0,0.0 +2026-04-08 11:00:00,100000.0,0.0,0.0 +2026-04-08 12:00:00,100000.0,0.0,0.0 +2026-04-08 13:00:00,100000.0,0.0,0.0 +2026-04-08 14:00:00,100000.0,0.0,0.0 +2026-04-08 15:00:00,100000.0,0.0,0.0 +2026-04-08 16:00:00,100000.0,0.0,0.0 +2026-04-08 17:00:00,100000.0,0.0,0.0 +2026-04-08 18:00:00,100000.0,0.0,0.0 +2026-04-08 19:00:00,100000.0,0.0,0.0 +2026-04-08 20:00:00,100000.0,0.0,0.0 +2026-04-08 21:00:00,100000.0,0.0,0.0 +2026-04-08 22:00:00,100000.0,0.0,0.0 +2026-04-08 23:00:00,100000.0,0.0,0.0 +2026-04-09 01:00:00,100000.0,0.0,0.0 +2026-04-09 02:00:00,100000.0,0.0,0.0 +2026-04-09 03:00:00,100000.0,0.0,0.0 +2026-04-09 04:00:00,100000.0,0.0,0.0 +2026-04-09 05:00:00,100000.0,0.0,0.0 +2026-04-09 06:00:00,100000.0,0.0,0.0 +2026-04-09 07:00:00,100000.0,0.0,0.0 +2026-04-09 08:00:00,100000.0,0.0,0.0 +2026-04-09 09:00:00,100000.0,0.0,0.0 +2026-04-09 10:00:00,100000.0,0.0,0.0 +2026-04-09 11:00:00,100000.0,0.0,0.0 +2026-04-09 12:00:00,100000.0,0.0,0.0 +2026-04-09 13:00:00,100000.0,0.0,0.0 +2026-04-09 14:00:00,100000.0,0.0,0.0 +2026-04-09 15:00:00,100000.0,0.0,0.0 +2026-04-09 16:00:00,100000.0,0.0,0.0 +2026-04-09 17:00:00,100000.0,0.0,0.0 +2026-04-09 18:00:00,100000.0,0.0,0.0 +2026-04-09 19:00:00,100000.0,0.0,0.0 +2026-04-09 20:00:00,100000.0,0.0,0.0 +2026-04-09 21:00:00,100000.0,0.0,0.0 +2026-04-09 22:00:00,100000.0,0.0,0.0 +2026-04-09 23:00:00,100000.0,0.0,0.0 +2026-04-10 01:00:00,100000.0,0.0,0.0 +2026-04-10 02:00:00,100000.0,0.0,0.0 +2026-04-10 03:00:00,100000.0,0.0,0.0 +2026-04-10 04:00:00,100000.0,0.0,0.0 +2026-04-10 05:00:00,100000.0,0.0,0.0 +2026-04-10 06:00:00,100000.0,0.0,0.0 +2026-04-10 07:00:00,100000.0,0.0,0.0 +2026-04-10 08:00:00,100000.0,0.0,0.0 +2026-04-10 09:00:00,100000.0,0.0,0.0 +2026-04-10 10:00:00,100000.0,0.0,0.0 +2026-04-10 11:00:00,100000.0,0.0,0.0 +2026-04-10 12:00:00,100000.0,0.0,0.0 +2026-04-10 13:00:00,100000.0,0.0,0.0 +2026-04-10 14:00:00,100000.0,0.0,0.0 +2026-04-10 15:00:00,100000.0,0.0,0.0 +2026-04-10 16:00:00,100000.0,0.0,0.0 +2026-04-10 17:00:00,100000.0,0.0,0.0 +2026-04-10 18:00:00,100000.0,0.0,0.0 +2026-04-10 19:00:00,100000.0,0.0,0.0 +2026-04-10 20:00:00,100000.0,0.0,0.0 +2026-04-10 21:00:00,100000.0,0.0,0.0 +2026-04-10 22:00:00,100000.0,0.0,0.0 +2026-04-10 23:00:00,100000.0,0.0,0.0 +2026-04-13 01:00:00,100000.0,0.0,0.0 +2026-04-13 02:00:00,100000.0,0.0,0.0 +2026-04-13 03:00:00,100000.0,0.0,0.0 +2026-04-13 04:00:00,100000.0,0.0,0.0 +2026-04-13 05:00:00,100000.0,0.0,0.0 +2026-04-13 06:00:00,100000.0,0.0,0.0 +2026-04-13 07:00:00,100000.0,0.0,0.0 +2026-04-13 08:00:00,100000.0,0.0,0.0 +2026-04-13 09:00:00,100000.0,0.0,0.0 +2026-04-13 10:00:00,100000.0,0.0,0.0 +2026-04-13 11:00:00,100000.0,0.0,0.0 +2026-04-13 12:00:00,100000.0,0.0,0.0 +2026-04-13 13:00:00,100000.0,0.0,0.0 +2026-04-13 14:00:00,100000.0,0.0,0.0 +2026-04-13 15:00:00,100000.0,0.0,0.0 +2026-04-13 16:00:00,100000.0,0.0,0.0 +2026-04-13 17:00:00,100000.0,0.0,0.0 +2026-04-13 18:00:00,100000.0,0.0,0.0 +2026-04-13 19:00:00,100000.0,0.0,0.0 +2026-04-13 20:00:00,100000.0,0.0,0.0 +2026-04-13 21:00:00,100000.0,0.0,0.0 +2026-04-13 22:00:00,100000.0,0.0,0.0 +2026-04-13 23:00:00,100000.0,0.0,0.0 +2026-04-14 01:00:00,100000.0,0.0,0.0 +2026-04-14 02:00:00,100000.0,0.0,0.0 +2026-04-14 03:00:00,100000.0,0.0,0.0 +2026-04-14 04:00:00,100000.0,0.0,0.0 +2026-04-14 05:00:00,100000.0,0.0,0.0 +2026-04-14 06:00:00,100000.0,0.0,0.0 +2026-04-14 07:00:00,100000.0,0.0,0.0 +2026-04-14 08:00:00,100000.0,0.0,0.0 +2026-04-14 09:00:00,100000.0,0.0,0.0 +2026-04-14 10:00:00,100000.0,0.0,0.0 +2026-04-14 11:00:00,100000.0,0.0,0.0 +2026-04-14 12:00:00,100000.0,0.0,0.0 +2026-04-14 13:00:00,100000.0,0.0,0.0 +2026-04-14 14:00:00,100000.0,0.0,0.0 +2026-04-14 15:00:00,100000.0,0.0,0.0 +2026-04-14 16:00:00,100000.0,0.0,0.0 +2026-04-14 17:00:00,100000.0,0.0,0.0 +2026-04-14 18:00:00,100000.0,0.0,0.0 +2026-04-14 19:00:00,100000.0,0.0,0.0 +2026-04-14 20:00:00,100000.0,0.0,0.0 +2026-04-14 21:00:00,100000.0,0.0,0.0 +2026-04-14 22:00:00,100000.0,0.0,0.0 +2026-04-14 23:00:00,100000.0,0.0,0.0 +2026-04-15 01:00:00,100000.0,0.0,0.0 +2026-04-15 02:00:00,100000.0,0.0,0.0 +2026-04-15 03:00:00,100000.0,0.0,0.0 +2026-04-15 04:00:00,100000.0,0.0,0.0 +2026-04-15 05:00:00,100000.0,0.0,0.0 +2026-04-15 06:00:00,100000.0,0.0,0.0 +2026-04-15 07:00:00,100000.0,0.0,0.0 +2026-04-15 08:00:00,100000.0,0.0,0.0 +2026-04-15 09:00:00,100000.0,0.0,0.0 +2026-04-15 10:00:00,100000.0,0.0,0.0 +2026-04-15 11:00:00,100000.0,0.0,0.0 +2026-04-15 12:00:00,100000.0,0.0,0.0 +2026-04-15 13:00:00,100000.0,0.0,0.0 +2026-04-15 14:00:00,100000.0,0.0,0.0 +2026-04-15 15:00:00,100000.0,0.0,0.0 +2026-04-15 16:00:00,100000.0,0.0,0.0 +2026-04-15 17:00:00,100000.0,0.0,0.0 +2026-04-15 18:00:00,100000.0,0.0,0.0 +2026-04-15 19:00:00,100000.0,0.0,0.0 +2026-04-15 20:00:00,100000.0,0.0,0.0 +2026-04-15 21:00:00,100000.0,0.0,0.0 +2026-04-15 22:00:00,100000.0,0.0,0.0 +2026-04-15 23:00:00,100000.0,0.0,0.0 +2026-04-16 01:00:00,100000.0,0.0,0.0 +2026-04-16 02:00:00,100000.0,0.0,0.0 +2026-04-16 03:00:00,100000.0,0.0,0.0 +2026-04-16 04:00:00,100000.0,0.0,0.0 +2026-04-16 05:00:00,100000.0,0.0,0.0 +2026-04-16 06:00:00,100000.0,0.0,0.0 +2026-04-16 07:00:00,100000.0,0.0,0.0 +2026-04-16 08:00:00,100000.0,0.0,0.0 +2026-04-16 09:00:00,100000.0,0.0,0.0 +2026-04-16 10:00:00,100000.0,0.0,0.0 +2026-04-16 11:00:00,100000.0,0.0,0.0 +2026-04-16 12:00:00,100000.0,0.0,0.0 +2026-04-16 13:00:00,100000.0,0.0,0.0 +2026-04-16 14:00:00,100000.0,0.0,0.0 +2026-04-16 15:00:00,100000.0,0.0,0.0 +2026-04-16 16:00:00,100000.0,0.0,0.0 +2026-04-16 17:00:00,100000.0,0.0,0.0 +2026-04-16 18:00:00,100000.0,0.0,0.0 +2026-04-16 19:00:00,100000.0,0.0,0.0 +2026-04-16 20:00:00,100000.0,0.0,0.0 +2026-04-16 21:00:00,100000.0,0.0,0.0 +2026-04-16 22:00:00,100000.0,0.0,0.0 +2026-04-16 23:00:00,100000.0,0.0,0.0 +2026-04-17 01:00:00,100000.0,0.0,0.0 +2026-04-17 02:00:00,100000.0,0.0,0.0 +2026-04-17 03:00:00,100000.0,0.0,0.0 +2026-04-17 04:00:00,100000.0,0.0,0.0 +2026-04-17 05:00:00,100000.0,0.0,0.0 +2026-04-17 06:00:00,100000.0,0.0,0.0 +2026-04-17 07:00:00,100000.0,0.0,0.0 +2026-04-17 08:00:00,100000.0,0.0,0.0 +2026-04-17 09:00:00,100000.0,0.0,0.0 +2026-04-17 10:00:00,100000.0,0.0,0.0 +2026-04-17 11:00:00,100000.0,0.0,0.0 +2026-04-17 12:00:00,100000.0,0.0,0.0 +2026-04-17 13:00:00,100000.0,0.0,0.0 +2026-04-17 14:00:00,100000.0,0.0,0.0 +2026-04-17 15:00:00,100000.0,0.0,0.0 +2026-04-17 16:00:00,100000.0,0.0,0.0 +2026-04-17 17:00:00,100000.0,0.0,0.0 +2026-04-17 18:00:00,100000.0,0.0,0.0 +2026-04-17 19:00:00,100000.0,0.0,0.0 +2026-04-17 20:00:00,100000.0,0.0,0.0 +2026-04-17 21:00:00,100000.0,0.0,0.0 +2026-04-17 22:00:00,100000.0,0.0,0.0 +2026-04-17 23:00:00,100000.0,0.0,0.0 +2026-04-20 01:00:00,100000.0,0.0,0.0 +2026-04-20 02:00:00,100000.0,0.0,0.0 +2026-04-20 03:00:00,100000.0,0.0,0.0 +2026-04-20 04:00:00,100000.0,0.0,0.0 +2026-04-20 05:00:00,100000.0,0.0,0.0 +2026-04-20 06:00:00,100000.0,0.0,0.0 +2026-04-20 07:00:00,100000.0,0.0,0.0 +2026-04-20 08:00:00,100000.0,0.0,0.0 +2026-04-20 09:00:00,100000.0,0.0,0.0 +2026-04-20 10:00:00,100000.0,0.0,0.0 +2026-04-20 11:00:00,100000.0,0.0,0.0 +2026-04-20 12:00:00,100000.0,0.0,0.0 +2026-04-20 13:00:00,100000.0,0.0,0.0 +2026-04-20 14:00:00,100000.0,0.0,0.0 +2026-04-20 15:00:00,100000.0,0.0,0.0 +2026-04-20 16:00:00,100000.0,0.0,0.0 +2026-04-20 17:00:00,100000.0,0.0,0.0 +2026-04-20 18:00:00,100000.0,0.0,0.0 +2026-04-20 19:00:00,100000.0,0.0,0.0 +2026-04-20 20:00:00,100000.0,0.0,0.0 +2026-04-20 21:00:00,100000.0,0.0,0.0 +2026-04-20 22:00:00,100000.0,0.0,0.0 +2026-04-20 23:00:00,100000.0,0.0,0.0 +2026-04-21 01:00:00,100000.0,0.0,0.0 +2026-04-21 02:00:00,100000.0,0.0,0.0 +2026-04-21 03:00:00,100000.0,0.0,0.0 +2026-04-21 04:00:00,100000.0,0.0,0.0 +2026-04-21 05:00:00,100000.0,0.0,0.0 +2026-04-21 06:00:00,100000.0,0.0,0.0 +2026-04-21 07:00:00,100000.0,0.0,0.0 +2026-04-21 08:00:00,100000.0,0.0,0.0 +2026-04-21 09:00:00,100000.0,0.0,0.0 +2026-04-21 10:00:00,100000.0,0.0,0.0 +2026-04-21 11:00:00,100000.0,0.0,0.0 +2026-04-21 12:00:00,100000.0,0.0,0.0 +2026-04-21 13:00:00,100000.0,0.0,0.0 +2026-04-21 14:00:00,100000.0,0.0,0.0 +2026-04-21 15:00:00,100000.0,0.0,0.0 +2026-04-21 16:00:00,100000.0,0.0,0.0 +2026-04-21 17:00:00,100000.0,0.0,0.0 +2026-04-21 18:00:00,100000.0,0.0,0.0 +2026-04-21 19:00:00,100000.0,0.0,0.0 +2026-04-21 20:00:00,100000.0,0.0,0.0 +2026-04-21 21:00:00,100000.0,0.0,0.0 +2026-04-21 22:00:00,100000.0,0.0,0.0 +2026-04-21 23:00:00,100000.0,0.0,0.0 +2026-04-22 01:00:00,100000.0,0.0,0.0 +2026-04-22 02:00:00,100000.0,0.0,0.0 +2026-04-22 03:00:00,100000.0,0.0,0.0 +2026-04-22 04:00:00,100000.0,0.0,0.0 +2026-04-22 05:00:00,100000.0,0.0,0.0 +2026-04-22 06:00:00,100000.0,0.0,0.0 +2026-04-22 07:00:00,100000.0,0.0,0.0 +2026-04-22 08:00:00,100000.0,0.0,0.0 +2026-04-22 09:00:00,100000.0,0.0,0.0 +2026-04-22 10:00:00,100000.0,0.0,0.0 +2026-04-22 11:00:00,100000.0,0.0,0.0 +2026-04-22 12:00:00,100000.0,0.0,0.0 +2026-04-22 13:00:00,100000.0,0.0,0.0 +2026-04-22 14:00:00,100000.0,0.0,0.0 +2026-04-22 15:00:00,100000.0,0.0,0.0 +2026-04-22 16:00:00,100000.0,0.0,0.0 +2026-04-22 17:00:00,100000.0,0.0,0.0 +2026-04-22 18:00:00,100000.0,0.0,0.0 +2026-04-22 19:00:00,100000.0,0.0,0.0 +2026-04-22 20:00:00,100000.0,0.0,0.0 +2026-04-22 21:00:00,100000.0,0.0,0.0 +2026-04-22 22:00:00,100000.0,0.0,0.0 +2026-04-22 23:00:00,100000.0,0.0,0.0 +2026-04-23 01:00:00,100000.0,0.0,0.0 +2026-04-23 02:00:00,100000.0,0.0,0.0 +2026-04-23 03:00:00,100000.0,0.0,0.0 +2026-04-23 04:00:00,100000.0,0.0,0.0 +2026-04-23 05:00:00,100000.0,0.0,0.0 +2026-04-23 06:00:00,100000.0,0.0,0.0 +2026-04-23 07:00:00,100000.0,0.0,0.0 +2026-04-23 08:00:00,100000.0,0.0,0.0 +2026-04-23 09:00:00,100000.0,0.0,0.0 +2026-04-23 10:00:00,100000.0,0.0,0.0 +2026-04-23 11:00:00,100000.0,0.0,0.0 +2026-04-23 12:00:00,100000.0,0.0,0.0 +2026-04-23 13:00:00,100000.0,0.0,0.0 +2026-04-23 14:00:00,100000.0,0.0,0.0 +2026-04-23 15:00:00,100000.0,0.0,0.0 +2026-04-23 16:00:00,100000.0,0.0,0.0 +2026-04-23 17:00:00,100000.0,0.0,0.0 +2026-04-23 18:00:00,100000.0,0.0,0.0 +2026-04-23 19:00:00,100000.0,0.0,0.0 +2026-04-23 20:00:00,100000.0,0.0,0.0 +2026-04-23 21:00:00,100000.0,0.0,0.0 +2026-04-23 22:00:00,100000.0,0.0,0.0 +2026-04-23 23:00:00,100000.0,0.0,0.0 +2026-04-24 01:00:00,100000.0,0.0,0.0 +2026-04-24 02:00:00,100000.0,0.0,0.0 +2026-04-24 03:00:00,100000.0,0.0,0.0 +2026-04-24 04:00:00,100000.0,0.0,0.0 +2026-04-24 05:00:00,100000.0,0.0,0.0 +2026-04-24 06:00:00,100000.0,0.0,0.0 +2026-04-24 07:00:00,100000.0,0.0,0.0 +2026-04-24 08:00:00,100000.0,0.0,0.0 +2026-04-24 09:00:00,100000.0,0.0,0.0 +2026-04-24 10:00:00,100000.0,0.0,0.0 +2026-04-24 11:00:00,100000.0,0.0,0.0 +2026-04-24 12:00:00,100000.0,0.0,0.0 +2026-04-24 13:00:00,100000.0,0.0,0.0 +2026-04-24 14:00:00,100000.0,0.0,0.0 +2026-04-24 15:00:00,100000.0,0.0,0.0 +2026-04-24 16:00:00,100000.0,0.0,0.0 +2026-04-24 17:00:00,100000.0,0.0,0.0 +2026-04-24 18:00:00,100000.0,0.0,0.0 +2026-04-24 19:00:00,100000.0,0.0,0.0 +2026-04-24 20:00:00,100000.0,0.0,0.0 +2026-04-24 21:00:00,100000.0,0.0,0.0 +2026-04-24 22:00:00,100000.0,0.0,0.0 +2026-04-24 23:00:00,100000.0,0.0,0.0 +2026-04-27 01:00:00,100000.0,0.0,0.0 +2026-04-27 02:00:00,100000.0,0.0,0.0 +2026-04-27 03:00:00,100000.0,0.0,0.0 +2026-04-27 04:00:00,100000.0,0.0,0.0 +2026-04-27 05:00:00,100000.0,0.0,0.0 +2026-04-27 06:00:00,100000.0,0.0,0.0 +2026-04-27 07:00:00,100000.0,0.0,0.0 +2026-04-27 08:00:00,100000.0,0.0,0.0 +2026-04-27 09:00:00,100000.0,0.0,0.0 +2026-04-27 10:00:00,100000.0,0.0,0.0 +2026-04-27 11:00:00,100000.0,0.0,0.0 +2026-04-27 12:00:00,100000.0,0.0,0.0 +2026-04-27 13:00:00,100000.0,0.0,0.0 +2026-04-27 14:00:00,100000.0,0.0,0.0 +2026-04-27 15:00:00,100000.0,0.0,0.0 +2026-04-27 16:00:00,100000.0,0.0,0.0 +2026-04-27 17:00:00,100000.0,0.0,0.0 +2026-04-27 18:00:00,100000.0,0.0,0.0 +2026-04-27 19:00:00,100000.0,0.0,0.0 +2026-04-27 20:00:00,100000.0,0.0,0.0 +2026-04-27 21:00:00,100000.0,0.0,0.0 +2026-04-27 22:00:00,100000.0,0.0,0.0 +2026-04-27 23:00:00,100000.0,0.0,0.0 +2026-04-28 01:00:00,100000.0,0.0,0.0 +2026-04-28 02:00:00,100000.0,0.0,0.0 +2026-04-28 03:00:00,100000.0,0.0,0.0 +2026-04-28 04:00:00,100000.0,0.0,0.0 +2026-04-28 05:00:00,100000.0,0.0,0.0 +2026-04-28 06:00:00,100000.0,0.0,0.0 +2026-04-28 07:00:00,100000.0,0.0,0.0 +2026-04-28 08:00:00,100000.0,0.0,0.0 +2026-04-28 09:00:00,100000.0,0.0,0.0 +2026-04-28 10:00:00,100000.0,0.0,0.0 +2026-04-28 11:00:00,100000.0,0.0,0.0 +2026-04-28 12:00:00,100000.0,0.0,0.0 +2026-04-28 13:00:00,100000.0,0.0,0.0 +2026-04-28 14:00:00,100000.0,0.0,0.0 +2026-04-28 15:00:00,100000.0,0.0,0.0 +2026-04-28 16:00:00,100000.0,0.0,0.0 +2026-04-28 17:00:00,100000.0,0.0,0.0 +2026-04-28 18:00:00,100000.0,0.0,0.0 +2026-04-28 19:00:00,100000.0,0.0,0.0 +2026-04-28 20:00:00,100000.0,0.0,0.0 +2026-04-28 21:00:00,100000.0,0.0,0.0 +2026-04-28 22:00:00,100000.0,0.0,0.0 +2026-04-28 23:00:00,100000.0,0.0,0.0 +2026-04-29 01:00:00,100000.0,0.0,0.0 +2026-04-29 02:00:00,100000.0,0.0,0.0 +2026-04-29 03:00:00,100000.0,0.0,0.0 +2026-04-29 04:00:00,100000.0,0.0,0.0 +2026-04-29 05:00:00,100000.0,0.0,0.0 +2026-04-29 06:00:00,100000.0,0.0,0.0 +2026-04-29 07:00:00,100000.0,0.0,0.0 +2026-04-29 08:00:00,100000.0,0.0,0.0 +2026-04-29 09:00:00,100000.0,0.0,0.0 +2026-04-29 10:00:00,100000.0,0.0,0.0 +2026-04-29 11:00:00,100000.0,0.0,0.0 +2026-04-29 12:00:00,100000.0,0.0,0.0 +2026-04-29 13:00:00,100000.0,0.0,0.0 +2026-04-29 14:00:00,100000.0,0.0,0.0 +2026-04-29 15:00:00,100000.0,0.0,0.0 +2026-04-29 16:00:00,100000.0,0.0,0.0 +2026-04-29 17:00:00,100000.0,0.0,0.0 +2026-04-29 18:00:00,100000.0,0.0,0.0 +2026-04-29 19:00:00,100000.0,0.0,0.0 +2026-04-29 20:00:00,100000.0,0.0,0.0 +2026-04-29 21:00:00,100000.0,0.0,0.0 +2026-04-29 22:00:00,100000.0,0.0,0.0 +2026-04-29 23:00:00,100000.0,0.0,0.0 +2026-04-30 01:00:00,100000.0,0.0,0.0 +2026-04-30 02:00:00,100000.0,0.0,0.0 +2026-04-30 03:00:00,100000.0,0.0,0.0 +2026-04-30 04:00:00,100000.0,0.0,0.0 +2026-04-30 05:00:00,100000.0,0.0,0.0 +2026-04-30 06:00:00,100000.0,0.0,0.0 +2026-04-30 07:00:00,100000.0,0.0,0.0 +2026-04-30 08:00:00,100000.0,0.0,0.0 +2026-04-30 09:00:00,100000.0,0.0,0.0 +2026-04-30 10:00:00,100000.0,0.0,0.0 +2026-04-30 11:00:00,100000.0,0.0,0.0 +2026-04-30 12:00:00,100000.0,0.0,0.0 +2026-04-30 13:00:00,100000.0,0.0,0.0 +2026-04-30 14:00:00,100000.0,0.0,0.0 +2026-04-30 15:00:00,100000.0,0.0,0.0 +2026-04-30 16:00:00,100000.0,0.0,0.0 +2026-04-30 17:00:00,100000.0,0.0,0.0 +2026-04-30 18:00:00,100000.0,0.0,0.0 +2026-04-30 19:00:00,100000.0,0.0,0.0 +2026-04-30 20:00:00,100000.0,0.0,0.0 +2026-04-30 21:00:00,100000.0,0.0,0.0 +2026-04-30 22:00:00,100000.0,0.0,0.0 +2026-04-30 23:00:00,100000.0,0.0,0.0 +2026-05-01 01:00:00,100000.0,0.0,0.0 +2026-05-01 02:00:00,100000.0,0.0,0.0 +2026-05-01 03:00:00,100000.0,0.0,0.0 +2026-05-01 04:00:00,100000.0,0.0,0.0 +2026-05-01 05:00:00,100000.0,0.0,0.0 +2026-05-01 06:00:00,100000.0,0.0,0.0 +2026-05-01 07:00:00,100000.0,0.0,0.0 +2026-05-01 08:00:00,100000.0,0.0,0.0 +2026-05-01 09:00:00,100000.0,0.0,0.0 +2026-05-01 10:00:00,100000.0,0.0,0.0 +2026-05-01 11:00:00,100000.0,0.0,0.0 +2026-05-01 12:00:00,100000.0,0.0,0.0 +2026-05-01 13:00:00,100000.0,0.0,0.0 +2026-05-01 14:00:00,100000.0,0.0,0.0 +2026-05-01 15:00:00,100000.0,0.0,0.0 +2026-05-01 16:00:00,100000.0,0.0,0.0 +2026-05-01 17:00:00,100000.0,0.0,0.0 +2026-05-01 18:00:00,100000.0,0.0,0.0 +2026-05-01 19:00:00,100000.0,0.0,0.0 +2026-05-01 20:00:00,100000.0,0.0,0.0 +2026-05-01 21:00:00,100000.0,0.0,0.0 +2026-05-01 22:00:00,100000.0,0.0,0.0 +2026-05-01 23:00:00,100000.0,0.0,0.0 +2026-05-04 01:00:00,100000.0,0.0,0.0 +2026-05-04 02:00:00,100000.0,0.0,0.0 +2026-05-04 03:00:00,100000.0,0.0,0.0 +2026-05-04 04:00:00,100000.0,0.0,0.0 +2026-05-04 05:00:00,100000.0,0.0,0.0 +2026-05-04 06:00:00,100000.0,0.0,0.0 +2026-05-04 07:00:00,100000.0,0.0,0.0 +2026-05-04 08:00:00,100000.0,0.0,0.0 +2026-05-04 09:00:00,100000.0,0.0,0.0 +2026-05-04 10:00:00,100000.0,0.0,0.0 +2026-05-04 11:00:00,100000.0,0.0,0.0 +2026-05-04 12:00:00,100000.0,0.0,0.0 +2026-05-04 13:00:00,100000.0,0.0,0.0 +2026-05-04 14:00:00,100000.0,0.0,0.0 +2026-05-04 15:00:00,100000.0,0.0,0.0 +2026-05-04 16:00:00,100000.0,0.0,0.0 +2026-05-04 17:00:00,100000.0,0.0,0.0 +2026-05-04 18:00:00,100000.0,0.0,0.0 +2026-05-04 19:00:00,100000.0,0.0,0.0 +2026-05-04 20:00:00,100000.0,0.0,0.0 +2026-05-04 21:00:00,100000.0,0.0,0.0 +2026-05-04 22:00:00,100000.0,0.0,0.0 +2026-05-04 23:00:00,100000.0,0.0,0.0 +2026-05-05 01:00:00,100000.0,0.0,0.0 +2026-05-05 02:00:00,100000.0,0.0,0.0 +2026-05-05 03:00:00,100000.0,0.0,0.0 +2026-05-05 04:00:00,100000.0,0.0,0.0 +2026-05-05 05:00:00,100000.0,0.0,0.0 +2026-05-05 06:00:00,100000.0,0.0,0.0 +2026-05-05 07:00:00,100000.0,0.0,0.0 +2026-05-05 08:00:00,100000.0,0.0,0.0 +2026-05-05 09:00:00,100000.0,0.0,0.0 +2026-05-05 10:00:00,100000.0,0.0,0.0 +2026-05-05 11:00:00,100000.0,0.0,0.0 +2026-05-05 12:00:00,100000.0,0.0,0.0 +2026-05-05 13:00:00,100000.0,0.0,0.0 +2026-05-05 14:00:00,100000.0,0.0,0.0 +2026-05-05 15:00:00,100000.0,0.0,0.0 +2026-05-05 16:00:00,100000.0,0.0,0.0 +2026-05-05 17:00:00,100000.0,0.0,0.0 +2026-05-05 18:00:00,100000.0,0.0,0.0 +2026-05-05 19:00:00,100000.0,0.0,0.0 +2026-05-05 20:00:00,100000.0,0.0,0.0 +2026-05-05 21:00:00,100000.0,0.0,0.0 +2026-05-05 22:00:00,100000.0,0.0,0.0 +2026-05-05 23:00:00,100000.0,0.0,0.0 +2026-05-06 01:00:00,100000.0,0.0,0.0 +2026-05-06 02:00:00,100000.0,0.0,0.0 +2026-05-06 03:00:00,100000.0,0.0,0.0 +2026-05-06 04:00:00,100000.0,0.0,0.0 +2026-05-06 05:00:00,100000.0,0.0,0.0 +2026-05-06 06:00:00,100000.0,0.0,0.0 +2026-05-06 07:00:00,100000.0,0.0,0.0 +2026-05-06 08:00:00,100000.0,0.0,0.0 +2026-05-06 09:00:00,100000.0,0.0,0.0 +2026-05-06 10:00:00,100000.0,0.0,0.0 +2026-05-06 11:00:00,100000.0,0.0,0.0 +2026-05-06 12:00:00,100000.0,0.0,0.0 +2026-05-06 13:00:00,100000.0,0.0,0.0 +2026-05-06 14:00:00,100000.0,0.0,0.0 +2026-05-06 15:00:00,100000.0,0.0,0.0 +2026-05-06 16:00:00,100000.0,0.0,0.0 +2026-05-06 17:00:00,100000.0,0.0,0.0 +2026-05-06 18:00:00,100000.0,0.0,0.0 +2026-05-06 19:00:00,100000.0,0.0,0.0 +2026-05-06 20:00:00,100000.0,0.0,0.0 +2026-05-06 21:00:00,100000.0,0.0,0.0 +2026-05-06 22:00:00,100000.0,0.0,0.0 +2026-05-06 23:00:00,100000.0,0.0,0.0 +2026-05-07 01:00:00,100000.0,0.0,0.0 +2026-05-07 02:00:00,100000.0,0.0,0.0 +2026-05-07 03:00:00,100000.0,0.0,0.0 +2026-05-07 04:00:00,100000.0,0.0,0.0 +2026-05-07 05:00:00,100000.0,0.0,0.0 +2026-05-07 06:00:00,100000.0,0.0,0.0 +2026-05-07 07:00:00,100000.0,0.0,0.0 +2026-05-07 08:00:00,100000.0,0.0,0.0 +2026-05-07 09:00:00,100000.0,0.0,0.0 +2026-05-07 10:00:00,100000.0,0.0,0.0 +2026-05-07 11:00:00,100000.0,0.0,0.0 +2026-05-07 12:00:00,100000.0,0.0,0.0 +2026-05-07 13:00:00,100000.0,0.0,0.0 +2026-05-07 14:00:00,100000.0,0.0,0.0 +2026-05-07 15:00:00,100000.0,0.0,0.0 +2026-05-07 16:00:00,100000.0,0.0,0.0 +2026-05-07 17:00:00,100000.0,0.0,0.0 +2026-05-07 18:00:00,100000.0,0.0,0.0 +2026-05-07 19:00:00,100000.0,0.0,0.0 +2026-05-07 20:00:00,100000.0,0.0,0.0 +2026-05-07 21:00:00,100000.0,0.0,0.0 +2026-05-07 22:00:00,100000.0,0.0,0.0 +2026-05-07 23:00:00,100000.0,0.0,0.0 +2026-05-08 01:00:00,100000.0,0.0,0.0 +2026-05-08 02:00:00,100000.0,0.0,0.0 +2026-05-08 03:00:00,100000.0,0.0,0.0 +2026-05-08 04:00:00,100000.0,0.0,0.0 +2026-05-08 05:00:00,100000.0,0.0,0.0 +2026-05-08 06:00:00,100000.0,0.0,0.0 +2026-05-08 07:00:00,100000.0,0.0,0.0 +2026-05-08 08:00:00,100000.0,0.0,0.0 +2026-05-08 09:00:00,100000.0,0.0,0.0 +2026-05-08 10:00:00,100000.0,0.0,0.0 +2026-05-08 11:00:00,100000.0,0.0,0.0 +2026-05-08 12:00:00,100000.0,0.0,0.0 +2026-05-08 13:00:00,100000.0,0.0,0.0 +2026-05-08 14:00:00,100000.0,0.0,0.0 +2026-05-08 15:00:00,100000.0,0.0,0.0 +2026-05-08 16:00:00,100000.0,0.0,0.0 +2026-05-08 17:00:00,100000.0,0.0,0.0 +2026-05-08 18:00:00,100000.0,0.0,0.0 +2026-05-08 19:00:00,100000.0,0.0,0.0 +2026-05-08 20:00:00,100000.0,0.0,0.0 +2026-05-08 21:00:00,100000.0,0.0,0.0 +2026-05-08 22:00:00,100000.0,0.0,0.0 +2026-05-08 23:00:00,100000.0,0.0,0.0 +2026-05-11 01:00:00,100000.0,0.0,0.0 +2026-05-11 02:00:00,100000.0,0.0,0.0 +2026-05-11 03:00:00,100000.0,0.0,0.0 +2026-05-11 04:00:00,100000.0,0.0,0.0 +2026-05-11 05:00:00,100000.0,0.0,0.0 +2026-05-11 06:00:00,100000.0,0.0,0.0 +2026-05-11 07:00:00,100000.0,0.0,0.0 +2026-05-11 08:00:00,100000.0,0.0,0.0 +2026-05-11 09:00:00,100000.0,0.0,0.0 +2026-05-11 10:00:00,100000.0,0.0,0.0 +2026-05-11 11:00:00,100000.0,0.0,0.0 +2026-05-11 12:00:00,100000.0,0.0,0.0 +2026-05-11 13:00:00,100000.0,0.0,0.0 +2026-05-11 14:00:00,100000.0,0.0,0.0 +2026-05-11 15:00:00,100000.0,0.0,0.0 +2026-05-11 16:00:00,100000.0,0.0,0.0 +2026-05-11 17:00:00,100000.0,0.0,0.0 +2026-05-11 18:00:00,100000.0,0.0,0.0 +2026-05-11 19:00:00,100000.0,0.0,0.0 +2026-05-11 20:00:00,100000.0,0.0,0.0 +2026-05-11 21:00:00,100000.0,0.0,0.0 +2026-05-11 22:00:00,100000.0,0.0,0.0 +2026-05-11 23:00:00,100000.0,0.0,0.0 +2026-05-12 01:00:00,100000.0,0.0,0.0 +2026-05-12 02:00:00,100000.0,0.0,0.0 +2026-05-12 03:00:00,100000.0,0.0,0.0 +2026-05-12 04:00:00,100000.0,0.0,0.0 +2026-05-12 05:00:00,100000.0,0.0,0.0 +2026-05-12 06:00:00,100000.0,0.0,0.0 +2026-05-12 07:00:00,100000.0,0.0,0.0 +2026-05-12 08:00:00,100000.0,0.0,0.0 +2026-05-12 09:00:00,100000.0,0.0,0.0 +2026-05-12 10:00:00,100000.0,0.0,0.0 +2026-05-12 11:00:00,100000.0,0.0,0.0 +2026-05-12 12:00:00,100000.0,0.0,0.0 +2026-05-12 13:00:00,100000.0,0.0,0.0 +2026-05-12 14:00:00,100000.0,0.0,0.0 +2026-05-12 15:00:00,100000.0,0.0,0.0 +2026-05-12 16:00:00,100000.0,0.0,0.0 +2026-05-12 17:00:00,100000.0,0.0,0.0 +2026-05-12 18:00:00,100000.0,0.0,0.0 +2026-05-12 19:00:00,100000.0,0.0,0.0 +2026-05-12 20:00:00,100000.0,0.0,0.0 +2026-05-12 21:00:00,100000.0,0.0,0.0 +2026-05-12 22:00:00,100000.0,0.0,0.0 +2026-05-12 23:00:00,100000.0,0.0,0.0 +2026-05-13 01:00:00,100000.0,0.0,0.0 +2026-05-13 02:00:00,100000.0,0.0,0.0 +2026-05-13 03:00:00,100000.0,0.0,0.0 +2026-05-13 04:00:00,100000.0,0.0,0.0 +2026-05-13 05:00:00,100000.0,0.0,0.0 +2026-05-13 06:00:00,100000.0,0.0,0.0 +2026-05-13 07:00:00,100000.0,0.0,0.0 +2026-05-13 08:00:00,100000.0,0.0,0.0 +2026-05-13 09:00:00,100000.0,0.0,0.0 +2026-05-13 10:00:00,100000.0,0.0,0.0 +2026-05-13 11:00:00,100000.0,0.0,0.0 +2026-05-13 12:00:00,100000.0,0.0,0.0 +2026-05-13 13:00:00,100000.0,0.0,0.0 +2026-05-13 14:00:00,100000.0,0.0,0.0 +2026-05-13 15:00:00,100000.0,0.0,0.0 +2026-05-13 16:00:00,100000.0,0.0,0.0 +2026-05-13 17:00:00,100000.0,0.0,0.0 +2026-05-13 18:00:00,100000.0,0.0,0.0 +2026-05-13 19:00:00,100000.0,0.0,0.0 +2026-05-13 20:00:00,100000.0,0.0,0.0 +2026-05-13 21:00:00,100000.0,0.0,0.0 +2026-05-13 22:00:00,100000.0,0.0,0.0 +2026-05-13 23:00:00,100000.0,0.0,0.0 +2026-05-14 01:00:00,100000.0,0.0,0.0 +2026-05-14 02:00:00,100000.0,0.0,0.0 +2026-05-14 03:00:00,100000.0,0.0,0.0 +2026-05-14 04:00:00,100000.0,0.0,0.0 +2026-05-14 05:00:00,100000.0,0.0,0.0 +2026-05-14 06:00:00,100000.0,0.0,0.0 +2026-05-14 07:00:00,100000.0,0.0,0.0 +2026-05-14 08:00:00,100000.0,0.0,0.0 +2026-05-14 09:00:00,100000.0,0.0,0.0 +2026-05-14 10:00:00,100000.0,0.0,0.0 +2026-05-14 11:00:00,100000.0,0.0,0.0 +2026-05-14 12:00:00,100000.0,0.0,0.0 +2026-05-14 13:00:00,100000.0,0.0,0.0 +2026-05-14 14:00:00,100000.0,0.0,0.0 +2026-05-14 15:00:00,100000.0,0.0,0.0 +2026-05-14 16:00:00,100000.0,0.0,0.0 +2026-05-14 17:00:00,100000.0,0.0,0.0 +2026-05-14 18:00:00,100000.0,0.0,0.0 +2026-05-14 19:00:00,100000.0,0.0,0.0 +2026-05-14 20:00:00,100000.0,0.0,0.0 +2026-05-14 21:00:00,100000.0,0.0,0.0 +2026-05-14 22:00:00,100000.0,0.0,0.0 +2026-05-14 23:00:00,100000.0,0.0,0.0 +2026-05-15 01:00:00,100000.0,0.0,0.0 +2026-05-15 02:00:00,100000.0,0.0,0.0 +2026-05-15 03:00:00,100000.0,0.0,0.0 +2026-05-15 04:00:00,100000.0,0.0,0.0 +2026-05-15 05:00:00,100000.0,0.0,0.0 +2026-05-15 06:00:00,100000.0,0.0,0.0 +2026-05-15 07:00:00,100000.0,0.0,0.0 +2026-05-15 08:00:00,100000.0,0.0,0.0 +2026-05-15 09:00:00,100000.0,0.0,0.0 +2026-05-15 10:00:00,100000.0,0.0,0.0 +2026-05-15 11:00:00,100000.0,0.0,0.0 +2026-05-15 12:00:00,100000.0,0.0,0.0 +2026-05-15 13:00:00,100000.0,0.0,0.0 +2026-05-15 14:00:00,100000.0,0.0,0.0 +2026-05-15 15:00:00,100000.0,0.0,0.0 +2026-05-15 16:00:00,100000.0,0.0,0.0 +2026-05-15 17:00:00,100000.0,0.0,0.0 +2026-05-15 18:00:00,100000.0,0.0,0.0 +2026-05-15 19:00:00,100000.0,0.0,0.0 +2026-05-15 20:00:00,100000.0,0.0,0.0 +2026-05-15 21:00:00,100000.0,0.0,0.0 +2026-05-15 22:00:00,100000.0,0.0,0.0 +2026-05-15 23:00:00,100000.0,0.0,0.0 +2026-05-18 01:00:00,100000.0,0.0,0.0 +2026-05-18 02:00:00,100000.0,0.0,0.0 +2026-05-18 03:00:00,100000.0,0.0,0.0 +2026-05-18 04:00:00,100000.0,0.0,0.0 +2026-05-18 05:00:00,100000.0,0.0,0.0 +2026-05-18 06:00:00,100000.0,0.0,0.0 +2026-05-18 07:00:00,100000.0,0.0,0.0 +2026-05-18 08:00:00,100000.0,0.0,0.0 +2026-05-18 09:00:00,100000.0,0.0,0.0 +2026-05-18 10:00:00,100000.0,0.0,0.0 +2026-05-18 11:00:00,100000.0,0.0,0.0 +2026-05-18 12:00:00,100000.0,0.0,0.0 +2026-05-18 13:00:00,100000.0,0.0,0.0 +2026-05-18 14:00:00,100000.0,0.0,0.0 +2026-05-18 15:00:00,100000.0,0.0,0.0 +2026-05-18 16:00:00,100000.0,0.0,0.0 +2026-05-18 17:00:00,100000.0,0.0,0.0 +2026-05-18 18:00:00,100000.0,0.0,0.0 +2026-05-18 19:00:00,100000.0,0.0,0.0 +2026-05-18 20:00:00,100000.0,0.0,0.0 +2026-05-18 21:00:00,100000.0,0.0,0.0 +2026-05-18 22:00:00,100000.0,0.0,0.0 +2026-05-18 23:00:00,100000.0,0.0,0.0 +2026-05-19 01:00:00,100000.0,0.0,0.0 +2026-05-19 02:00:00,100000.0,0.0,0.0 +2026-05-19 03:00:00,100000.0,0.0,0.0 +2026-05-19 04:00:00,100000.0,0.0,0.0 +2026-05-19 05:00:00,100000.0,0.0,0.0 +2026-05-19 06:00:00,100000.0,0.0,0.0 +2026-05-19 07:00:00,100000.0,0.0,0.0 +2026-05-19 08:00:00,100000.0,0.0,0.0 +2026-05-19 09:00:00,100000.0,0.0,0.0 +2026-05-19 10:00:00,100000.0,0.0,0.0 +2026-05-19 11:00:00,100000.0,0.0,0.0 +2026-05-19 12:00:00,100000.0,0.0,0.0 +2026-05-19 13:00:00,100000.0,0.0,0.0 +2026-05-19 14:00:00,100000.0,0.0,0.0 +2026-05-19 15:00:00,100000.0,0.0,0.0 +2026-05-19 16:00:00,100000.0,0.0,0.0 +2026-05-19 17:00:00,100000.0,0.0,0.0 +2026-05-19 18:00:00,100000.0,0.0,0.0 +2026-05-19 19:00:00,100000.0,0.0,0.0 +2026-05-19 20:00:00,100000.0,0.0,0.0 +2026-05-19 21:00:00,100000.0,0.0,0.0 +2026-05-19 22:00:00,100000.0,0.0,0.0 +2026-05-19 23:00:00,100000.0,0.0,0.0 +2026-05-20 01:00:00,100000.0,0.0,0.0 diff --git a/backtest_output/ferro_sar_adx_cci_metrics.csv b/backtest_output/ferro_sar_adx_cci_metrics.csv new file mode 100644 index 0000000..601d1a4 --- /dev/null +++ b/backtest_output/ferro_sar_adx_cci_metrics.csv @@ -0,0 +1,43 @@ +metric,value +Start Value,100000.0 +End Value,100000.0 +Total Return [%],0.0 +Total Fees Paid,-0.0 +Max Drawdown [%],0.0 +Max Drawdown Duration,0.0 +Total Trades,0.0 +Total Closed Trades,0.0 +Total Open Trades,0.0 +Open Trade PnL,-0.0 +Win Rate [%],0.0 +Best Trade [%],0.0 +Worst Trade [%],0.0 +Avg Winning Trade [%],0.0 +Avg Losing Trade [%],0.0 +Avg Winning Trade Duration,0.0 +Avg Losing Trade Duration,0.0 +Profit Factor,0.0 +Expectancy,0.0 +SQN,0.0 +Sharpe Ratio,0.0 +Sortino Ratio,0.0 +Calmar Ratio,0.0 +Omega Ratio,1.0 +total_trades,0.0 +total_closed_trades,0.0 +total_open_trades,0.0 +winning_trades,0.0 +losing_trades,0.0 +max_consecutive_wins,0.0 +max_consecutive_losses,0.0 +avg_holding_period,0.0 +avg_winning_duration,0.0 +avg_losing_duration,0.0 +start_value,100000.0 +end_value,100000.0 +total_fees_paid,-0.0 +open_trade_pnl,-0.0 +exposure_pct,0.0 +payoff_ratio,0.0 +recovery_factor,0.0 +omega_ratio,1.0 diff --git a/backtest_output/multi_strategy_curves.csv b/backtest_output/multi_strategy_curves.csv new file mode 100644 index 0000000..3e618b1 --- /dev/null +++ b/backtest_output/multi_strategy_curves.csv @@ -0,0 +1,818 @@ +time,equity,drawdown,returns +2026-03-30 13:00:00,100000.0,0.0,0.0 +2026-03-30 14:00:00,100000.0,0.0,0.0 +2026-03-30 15:00:00,100000.0,0.0,0.0 +2026-03-30 16:00:00,100000.0,0.0,0.0 +2026-03-30 17:00:00,100000.0,0.0,0.0 +2026-03-30 18:00:00,100000.0,0.0,0.0 +2026-03-30 19:00:00,100000.0,0.0,0.0 +2026-03-30 20:00:00,100000.0,0.0,0.0 +2026-03-30 21:00:00,100000.0,0.0,0.0 +2026-03-30 22:00:00,100000.0,0.0,0.0 +2026-03-30 23:00:00,100000.0,0.0,0.0 +2026-03-31 01:00:00,100000.0,0.0,0.0 +2026-03-31 02:00:00,100000.0,0.0,0.0 +2026-03-31 03:00:00,100000.0,0.0,0.0 +2026-03-31 04:00:00,100000.0,0.0,0.0 +2026-03-31 05:00:00,100000.0,0.0,0.0 +2026-03-31 06:00:00,100000.0,0.0,0.0 +2026-03-31 07:00:00,100000.0,0.0,0.0 +2026-03-31 08:00:00,100000.0,0.0,0.0 +2026-03-31 09:00:00,100000.0,0.0,0.0 +2026-03-31 10:00:00,100000.0,0.0,0.0 +2026-03-31 11:00:00,100000.0,0.0,0.0 +2026-03-31 12:00:00,100000.0,0.0,0.0 +2026-03-31 13:00:00,100000.0,0.0,0.0 +2026-03-31 14:00:00,100000.0,0.0,0.0 +2026-03-31 15:00:00,100000.0,0.0,0.0 +2026-03-31 16:00:00,100000.0,0.0,0.0 +2026-03-31 17:00:00,100000.0,0.0,0.0 +2026-03-31 18:00:00,100000.0,0.0,0.0 +2026-03-31 19:00:00,100000.0,0.0,0.0 +2026-03-31 20:00:00,100000.0,0.0,0.0 +2026-03-31 21:00:00,100000.0,0.0,0.0 +2026-03-31 22:00:00,100000.0,0.0,0.0 +2026-03-31 23:00:00,100000.0,0.0,0.0 +2026-04-01 01:00:00,100000.0,0.0,0.0 +2026-04-01 02:00:00,100000.0,0.0,0.0 +2026-04-01 03:00:00,100000.0,0.0,0.0 +2026-04-01 04:00:00,100000.0,0.0,0.0 +2026-04-01 05:00:00,100000.0,0.0,0.0 +2026-04-01 06:00:00,100000.0,0.0,0.0 +2026-04-01 07:00:00,100000.0,0.0,0.0 +2026-04-01 08:00:00,100000.0,0.0,0.0 +2026-04-01 09:00:00,100000.0,0.0,0.0 +2026-04-01 10:00:00,100000.0,0.0,0.0 +2026-04-01 11:00:00,100000.0,0.0,0.0 +2026-04-01 12:00:00,100000.0,0.0,0.0 +2026-04-01 13:00:00,100000.0,0.0,0.0 +2026-04-01 14:00:00,100000.0,0.0,0.0 +2026-04-01 15:00:00,100000.0,0.0,0.0 +2026-04-01 16:00:00,100000.0,0.0,0.0 +2026-04-01 17:00:00,100000.0,0.0,0.0 +2026-04-01 18:00:00,100000.0,0.0,0.0 +2026-04-01 19:00:00,100000.0,0.0,0.0 +2026-04-01 20:00:00,100000.0,0.0,0.0 +2026-04-01 21:00:00,100000.0,0.0,0.0 +2026-04-01 22:00:00,100000.0,0.0,0.0 +2026-04-01 23:00:00,100000.0,0.0,0.0 +2026-04-02 01:00:00,100000.0,0.0,0.0 +2026-04-02 02:00:00,100000.0,0.0,0.0 +2026-04-02 03:00:00,100000.0,0.0,0.0 +2026-04-02 04:00:00,100000.0,0.0,0.0 +2026-04-02 05:00:00,100000.0,0.0,0.0 +2026-04-02 06:00:00,100000.0,0.0,0.0 +2026-04-02 07:00:00,100000.0,0.0,0.0 +2026-04-02 08:00:00,99900.0999000999,0.09990009990010003,-0.0009990009990010003 +2026-04-02 09:00:00,100009.6928912141,0.0,0.0010970258410531535 +2026-04-02 10:00:00,100953.62776110844,0.0,0.009438433841818757 +2026-04-02 11:00:00,100479.59458525735,0.4695553655316038,-0.004695553655316038 +2026-04-02 12:00:00,100499.38220865297,0.44995465990620137,0.00019693175989906657 +2026-04-02 13:00:00,100473.72353216194,0.47537095950838015,-0.00025531178328798177 +2026-04-02 14:00:00,100029.04562178785,0.91584835515616,-0.00442581298613604 +2026-04-02 15:00:00,100066.22895805874,0.8790162599699678,0.0003717253927571881 +2026-04-02 16:00:00,101034.51782597844,0.0,0.00967648004728498 +2026-04-02 17:00:00,101797.97217479187,0.0,0.007556371478195143 +2026-04-02 18:00:00,101354.38149647247,0.4357559083374739,-0.004357559083374739 +2026-04-02 19:00:00,101312.84923198276,0.47655462328475867,-0.0004097727584786763 +2026-04-02 20:00:00,101087.13985742614,0.6982774825270528,-0.002227845493120046 +2026-04-02 21:00:00,101517.90119750002,0.2751243185973823,0.004261287248619612 +2026-04-02 22:00:00,101594.00744132932,0.20036227550027313,0.0007496829911922498 +2026-04-02 23:00:00,101678.81154159625,0.11705599890635274,0.0008347352605015483 +2026-04-06 01:00:00,101022.99316619859,0.7612912045660412,-0.0064499020538745285 +2026-04-06 02:00:00,100262.80051354929,1.5080572121875075,-0.007524946834614857 +2026-04-06 03:00:00,100510.68942202188,1.2645465575283368,0.002472391626833692 +2026-04-06 04:00:00,100592.44927253565,1.184230762658291,0.0008134443309853423 +2026-04-06 05:00:00,101070.17903737276,0.7149387378458341,0.00474916127693428 +2026-04-06 06:00:00,100907.96401481086,0.8742886925614017,-0.0016049741289358432 +2026-04-06 07:00:00,101311.10966069525,0.47826346998410946,0.003995181647161342 +2026-04-06 08:00:00,101169.33460076178,0.61753447598218,-0.0013994028928149404 +2026-04-06 09:00:00,101360.77268138285,0.4294776055640147,0.001892254025160178 +2026-04-06 10:00:00,101360.77268138285,0.4294776055640147,0.0 +2026-04-06 11:00:00,101360.77268138285,0.4294776055640147,0.0 +2026-04-06 12:00:00,101259.51316821463,0.5289486569071103,-0.000999000999001026 +2026-04-06 13:00:00,101175.80693784915,0.6111764543545433,-0.0008266505313572595 +2026-04-06 14:00:00,100922.74660738852,0.859767192513961,-0.002501194090955807 +2026-04-06 15:00:00,100877.65742659886,0.9040600009328114,-0.0004467692597097245 +2026-04-06 16:00:00,100962.01087008575,0.8211964215463244,0.000836195502936517 +2026-04-06 17:00:00,101029.75251012378,0.7546512452615591,0.0006709616761219139 +2026-04-06 18:00:00,100709.38201503939,1.0693633050797111,-0.0031710509738435083 +2026-04-06 19:00:00,100544.77414450106,1.2310638449054816,-0.0016344839700609469 +2026-04-06 20:00:00,100396.99381511868,1.376234054316565,-0.0014697962240185119 +2026-04-06 21:00:00,100500.33217683279,1.2747208713853064,0.0010292973702420401 +2026-04-06 22:00:00,100434.96365157314,1.3389348472269933,-0.0006504309373289469 +2026-04-06 23:00:00,100328.82069966638,1.4432030852273614,-0.001056832681047105 +2026-04-07 01:00:00,100400.69393253363,1.3725992889711462,0.0007163767336845373 +2026-04-07 02:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 03:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 04:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 05:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 06:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 07:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 08:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 09:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 10:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 11:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 12:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 13:00:00,100400.69393253363,1.3725992889711462,0.0 +2026-04-07 14:00:00,100300.39353899463,1.471128160810342,-0.0009990009990010588 +2026-04-07 15:00:00,100124.75307054672,1.6436664390250817,-0.001751144359963259 +2026-04-07 16:00:00,99966.97799288457,1.7986548678625904,-0.0015757849365280107 +2026-04-07 17:00:00,99730.85349057167,2.030608901197817,-0.0023620250111962785 +2026-04-07 18:00:00,100210.63609372162,1.5593002956333093,0.004810774061963831 +2026-04-07 19:00:00,100242.92294453926,1.527583700373244,0.0003221898600407681 +2026-04-07 20:00:00,100647.58480812017,1.1300690397805062,0.004036812292522618 +2026-04-07 21:00:00,100353.34397433554,1.4191129445838386,-0.0029234763491402635 +2026-04-07 22:00:00,101183.76177736494,0.6033621144950695,0.008274939031845023 +2026-04-07 23:00:00,101288.80166535827,0.5001774579156867,0.001038110129019064 +2026-04-08 01:00:00,103607.25025248305,0.0,0.02288948579710274 +2026-04-08 02:00:00,103607.25025248305,0.0,0.0 +2026-04-08 03:00:00,103607.25025248305,0.0,0.0 +2026-04-08 04:00:00,103607.25025248305,0.0,0.0 +2026-04-08 05:00:00,103607.25025248305,0.0,0.0 +2026-04-08 06:00:00,103607.25025248305,0.0,0.0 +2026-04-08 07:00:00,103607.25025248305,0.0,0.0 +2026-04-08 08:00:00,103607.25025248305,0.0,0.0 +2026-04-08 09:00:00,103607.25025248305,0.0,0.0 +2026-04-08 10:00:00,103607.25025248305,0.0,0.0 +2026-04-08 11:00:00,103607.25025248305,0.0,0.0 +2026-04-08 12:00:00,103607.25025248305,0.0,0.0 +2026-04-08 13:00:00,103607.25025248305,0.0,0.0 +2026-04-08 14:00:00,103607.25025248305,0.0,0.0 +2026-04-08 15:00:00,103607.25025248305,0.0,0.0 +2026-04-08 16:00:00,103607.25025248305,0.0,0.0 +2026-04-08 17:00:00,103607.25025248305,0.0,0.0 +2026-04-08 18:00:00,103607.25025248305,0.0,0.0 +2026-04-08 19:00:00,103607.25025248305,0.0,0.0 +2026-04-08 20:00:00,103607.25025248305,0.0,0.0 +2026-04-08 21:00:00,103607.25025248305,0.0,0.0 +2026-04-08 22:00:00,103607.25025248305,0.0,0.0 +2026-04-08 23:00:00,103607.25025248305,0.0,0.0 +2026-04-09 01:00:00,103607.25025248305,0.0,0.0 +2026-04-09 02:00:00,103607.25025248305,0.0,0.0 +2026-04-09 03:00:00,103607.25025248305,0.0,0.0 +2026-04-09 04:00:00,103607.25025248305,0.0,0.0 +2026-04-09 05:00:00,103607.25025248305,0.0,0.0 +2026-04-09 06:00:00,103607.25025248305,0.0,0.0 +2026-04-09 07:00:00,103607.25025248305,0.0,0.0 +2026-04-09 08:00:00,103607.25025248305,0.0,0.0 +2026-04-09 09:00:00,103607.25025248305,0.0,0.0 +2026-04-09 10:00:00,103607.25025248305,0.0,0.0 +2026-04-09 11:00:00,103607.25025248305,0.0,0.0 +2026-04-09 12:00:00,103607.25025248305,0.0,0.0 +2026-04-09 13:00:00,103607.25025248305,0.0,0.0 +2026-04-09 14:00:00,103607.25025248305,0.0,0.0 +2026-04-09 15:00:00,103503.74650597708,0.09990009990009524,-0.0009990009990009524 +2026-04-09 16:00:00,103915.08582352396,0.0,0.003974149066412075 +2026-04-09 17:00:00,104005.37982005865,0.0,0.0008689209638726793 +2026-04-09 18:00:00,104535.14822001696,0.0,0.005093663432361579 +2026-04-09 19:00:00,104447.90764365495,0.08345573507810763,-0.0008345573507810764 +2026-04-09 20:00:00,104641.3636217377,0.0,0.001852176673014541 +2026-04-09 21:00:00,104081.93342581631,0.5346166912958484,-0.005346166912958484 +2026-04-09 22:00:00,104009.52374743583,0.6038146412023835,-0.0006956988210840144 +2026-04-09 23:00:00,103936.67786617357,0.6734294462287995,-0.0007003770293107568 +2026-04-10 01:00:00,103909.19708461953,0.6996913187836904,-0.00026439926807574456 +2026-04-10 02:00:00,103898.72821545608,0.7096958416617507,-0.00010075016896652166 +2026-04-10 03:00:00,103883.89731747455,0.7238689157389819,-0.0001427437874964048 +2026-04-10 04:00:00,103913.1229105558,0.6959396227044421,0.0002813293863238356 +2026-04-10 05:00:00,103662.74245639684,0.9352144615378215,-0.0024095171730569654 +2026-04-10 06:00:00,103960.66902467312,0.650502414633276,0.002873998518817823 +2026-04-10 07:00:00,103786.6240748309,0.816827607480872,-0.0016741422643298221 +2026-04-10 08:00:00,103841.67440522542,0.7642190323542006,0.0005304183548240619 +2026-04-10 09:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 10:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 11:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 12:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 13:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 14:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 15:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 16:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 17:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 18:00:00,103841.67440522542,0.7642190323542006,0.0 +2026-04-10 19:00:00,103737.93646875667,0.863355676677518,-0.0009990009990009472 +2026-04-10 20:00:00,103659.32551202754,0.9384798474722471,-0.000757784079817353 +2026-04-10 21:00:00,103770.60030119258,0.8321406472614421,0.001073466266690351 +2026-04-10 22:00:00,103647.13101458481,0.9501334584542417,-0.0011898291640348986 +2026-04-10 23:00:00,103417.61315200178,1.169471065151247,-0.0022144159740488476 +2026-04-13 01:00:00,101458.43648249554,3.041748529527915,-0.018944323019974063 +2026-04-13 02:00:00,101726.93318511877,2.7851609877277075,0.002646371380555985 +2026-04-13 03:00:00,102253.25640510283,2.2821828137365565,0.005173882702492112 +2026-04-13 04:00:00,102811.15466310827,1.7490301113098572,0.005456043920940617 +2026-04-13 05:00:00,102795.47602353903,1.764013325429584,-0.00015249940165172556 +2026-04-13 06:00:00,102742.99613275866,1.8141654726914165,-0.0005105272411827349 +2026-04-13 07:00:00,102849.48022649976,1.71240447679498,0.001036412191089883 +2026-04-13 08:00:00,102987.75711714511,1.5802608522669146,0.0013444588182734325 +2026-04-13 09:00:00,102906.31529493823,1.6580903256110096,-0.0007907912987583627 +2026-04-13 10:00:00,103082.04671344347,1.4901534673524726,0.0017076835177858335 +2026-04-13 11:00:00,102857.31954628436,1.7049128697351374,-0.0021800805700320594 +2026-04-13 12:00:00,102666.99828262444,1.8867924411328323,-0.0018503424403771107 +2026-04-13 13:00:00,102830.97072145273,1.7300929934641007,0.0015971289856639656 +2026-04-13 14:00:00,102493.87997071409,2.05223209703807,-0.003278105305956422 +2026-04-13 15:00:00,102860.36817064506,1.7019994669896215,0.0035757081304336477 +2026-04-13 16:00:00,103013.67042421096,1.5554969289301401,0.0014903918417982767 +2026-04-13 17:00:00,102764.77202104927,1.7933554530806992,-0.0024161686709804663 +2026-04-13 18:00:00,102665.25621156118,1.8884572427016986,-0.0009683844719443943 +2026-04-13 19:00:00,103067.23910940585,1.5043042806877662,0.0039154716276781415 +2026-04-13 20:00:00,103000.82265011949,1.5677748405004732,-0.0006443993247540677 +2026-04-13 21:00:00,103185.04666505805,1.3917220745937684,0.001788568384199676 +2026-04-13 22:00:00,103245.58363450595,1.3338702200759573,0.0005866835496465519 +2026-04-13 23:00:00,103223.80774621533,1.3546802396866886,-0.00021091350858849024 +2026-04-14 01:00:00,103612.07183443692,0.9836375900275085,0.0037613811842339097 +2026-04-14 02:00:00,103603.36147912068,0.9919615978717982,-8.406699298671976e-05 +2026-04-14 03:00:00,103868.1562807345,0.7389117594054175,0.002555851449541912 +2026-04-14 04:00:00,103762.32546364215,0.8400484547135153,-0.0010188956931739155 +2026-04-14 05:00:00,103985.09280085508,0.627161954095839,0.0021469000065056595 +2026-04-14 06:00:00,103830.91951175756,0.7744968929397533,-0.0014826479925616571 +2026-04-14 07:00:00,103963.09915368156,0.6481800739026783,0.0012730277507465436 +2026-04-14 08:00:00,103653.88153995489,0.9436823523749265,-0.0029743016151295167 +2026-04-14 09:00:00,104137.08850112355,0.48190801721299337,0.004661735325197649 +2026-04-14 10:00:00,104029.95113073375,0.584293313697741,-0.0010288108869938588 +2026-04-14 11:00:00,104235.73327508001,0.38763862837642726,0.0019781047872228143 +2026-04-14 12:00:00,104052.59805455599,0.5626508933025905,-0.001756933201023571 +2026-04-14 13:00:00,103837.88779601055,0.76783768666433,-0.002063478111645672 +2026-04-14 14:00:00,103980.73762319697,0.6313239580179768,0.001375700432842501 +2026-04-14 15:00:00,103900.38459540463,0.7081129303815304,-0.0007727683956573126 +2026-04-14 16:00:00,104408.41606922456,0.22261517286339202,0.004889601475473337 +2026-04-14 17:00:00,104610.71407144434,0.02929009067978308,0.0019375641335814006 +2026-04-14 18:00:00,104703.04383779652,0.0,0.000882603346815162 +2026-04-14 19:00:00,104749.64423873843,0.0,0.00044507207463905565 +2026-04-14 20:00:00,105185.70182882136,0.0,0.004162855093704133 +2026-04-14 21:00:00,105185.70182882136,0.0,0.0 +2026-04-14 22:00:00,105185.70182882136,0.0,0.0 +2026-04-14 23:00:00,105185.70182882136,0.0,0.0 +2026-04-15 01:00:00,105185.70182882136,0.0,0.0 +2026-04-15 02:00:00,105185.70182882136,0.0,0.0 +2026-04-15 03:00:00,105185.70182882136,0.0,0.0 +2026-04-15 04:00:00,105185.70182882136,0.0,0.0 +2026-04-15 05:00:00,105185.70182882136,0.0,0.0 +2026-04-15 06:00:00,105185.70182882136,0.0,0.0 +2026-04-15 07:00:00,105185.70182882136,0.0,0.0 +2026-04-15 08:00:00,105185.70182882136,0.0,0.0 +2026-04-15 09:00:00,105185.70182882136,0.0,0.0 +2026-04-15 10:00:00,105185.70182882136,0.0,0.0 +2026-04-15 11:00:00,105185.70182882136,0.0,0.0 +2026-04-15 12:00:00,105185.70182882136,0.0,0.0 +2026-04-15 13:00:00,105185.70182882136,0.0,0.0 +2026-04-15 14:00:00,105185.70182882136,0.0,0.0 +2026-04-15 15:00:00,105185.70182882136,0.0,0.0 +2026-04-15 16:00:00,105185.70182882136,0.0,0.0 +2026-04-15 17:00:00,105185.70182882136,0.0,0.0 +2026-04-15 18:00:00,105185.70182882136,0.0,0.0 +2026-04-15 19:00:00,105185.70182882136,0.0,0.0 +2026-04-15 20:00:00,105185.70182882136,0.0,0.0 +2026-04-15 21:00:00,105185.70182882136,0.0,0.0 +2026-04-15 22:00:00,105185.70182882136,0.0,0.0 +2026-04-15 23:00:00,105185.70182882136,0.0,0.0 +2026-04-16 01:00:00,105185.70182882136,0.0,0.0 +2026-04-16 02:00:00,105185.70182882136,0.0,0.0 +2026-04-16 03:00:00,105185.70182882136,0.0,0.0 +2026-04-16 04:00:00,105185.70182882136,0.0,0.0 +2026-04-16 05:00:00,105185.70182882136,0.0,0.0 +2026-04-16 06:00:00,105080.62120761375,0.09990009990009933,-0.0009990009990009934 +2026-04-16 07:00:00,105275.13387339405,0.0,0.001851080280501912 +2026-04-16 08:00:00,105016.36450391253,0.24580293556570107,-0.002458029355657011 +2026-04-16 09:00:00,105121.78906184944,0.1456609988537498,0.001003886950714129 +2026-04-16 10:00:00,105012.00811722092,0.2499410321240464,-0.0010443215018337815 +2026-04-16 11:00:00,104854.52473831928,0.3995332227082291,-0.0014996701970106923 +2026-04-16 12:00:00,104834.26754020329,0.41877537170454104,-0.00019319336162688594 +2026-04-16 13:00:00,104929.8902280841,0.3279441522488564,0.0009121319786408357 +2026-04-16 14:00:00,105037.92861803598,0.22531935760190075,0.0010296245399384041 +2026-04-16 15:00:00,104907.23701728774,0.3494622543522466,-0.0012442324640986398 +2026-04-16 16:00:00,104440.88582195109,0.7924454909230947,-0.004445367246301643 +2026-04-16 17:00:00,104205.01362092067,1.0164985909781215,-0.0022584278098955394 +2026-04-16 18:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-16 19:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-16 20:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-16 21:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-16 22:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-16 23:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 01:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 02:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 03:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 04:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 05:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 06:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 07:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 08:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 09:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 10:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 11:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 12:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 13:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 14:00:00,104205.01362092067,1.0164985909781215,0.0 +2026-04-17 15:00:00,104100.91270821246,1.115383207770346,-0.000999000999000947 +2026-04-17 16:00:00,104728.34047148142,0.5193946393553966,0.006027111068925963 +2026-04-17 17:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-17 18:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-17 19:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-17 20:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-17 21:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-17 22:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-17 23:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 01:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 02:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 03:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 04:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 05:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 06:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 07:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 08:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 09:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 10:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 11:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 12:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 13:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 14:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 15:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 16:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 17:00:00,104728.34047148142,0.5193946393553966,0.0 +2026-04-20 18:00:00,104623.7167547267,0.6187758634919006,-0.000999000999000958 +2026-04-20 19:00:00,104751.45073007197,0.497442391241099,0.0012208892907591916 +2026-04-20 20:00:00,104778.91571453186,0.47135362417012117,0.0002621919244885951 +2026-04-20 21:00:00,104936.73038714276,0.3214467403653586,0.0015061682165223025 +2026-04-20 22:00:00,104938.69217174701,0.3195832570031696,1.8694927858103566e-05 +2026-04-20 23:00:00,105078.85078291938,0.18644772345834298,0.0013356237653789724 +2026-04-21 01:00:00,105233.61379059029,0.03943959155036182,0.0014728273721857231 +2026-04-21 02:00:00,105105.879815245,0.16077306380117729,-0.001213813445573272 +2026-04-21 03:00:00,105154.05252608341,0.115014194573482,0.0004583255563160258 +2026-04-21 04:00:00,104853.68150556162,0.40033420269907233,-0.0028564854449835597 +2026-04-21 05:00:00,104779.78761880043,0.47052540934246934,-0.0007047333550922332 +2026-04-21 06:00:00,104567.6969054712,0.6719886661684349,-0.002024156740046492 +2026-04-21 07:00:00,104390.91831501896,0.8399092224745698,-0.0016905659748061466 +2026-04-21 08:00:00,104282.80218571649,0.9426078611032327,-0.0010356852018124551 +2026-04-21 09:00:00,104062.88983716996,1.1515008260943722,-0.0021088074345651355 +2026-04-21 10:00:00,104062.88983716996,1.1515008260943722,0.0 +2026-04-21 11:00:00,104062.88983716996,1.1515008260943722,0.0 +2026-04-21 12:00:00,104062.88983716996,1.1515008260943722,0.0 +2026-04-21 13:00:00,104062.88983716996,1.1515008260943722,0.0 +2026-04-21 14:00:00,104062.88983716996,1.1515008260943722,0.0 +2026-04-21 15:00:00,104062.88983716996,1.1515008260943722,0.0 +2026-04-21 16:00:00,104062.88983716996,1.1515008260943722,0.0 +2026-04-21 17:00:00,103958.9309062637,1.2502505755188476,-0.0009990009990009402 +2026-04-21 18:00:00,104015.5268932102,1.1964905042995946,0.0005444071659175795 +2026-04-21 19:00:00,103344.49152860034,1.8339015812751573,-0.006451299961195212 +2026-04-21 20:00:00,103346.90453579572,1.8316094852154308,2.3349161234358885e-05 +2026-04-21 21:00:00,103305.88341347403,1.8705751182309371,-0.0003969264730854317 +2026-04-21 22:00:00,102648.66799916506,2.4948587359552827,-0.00636183915758715 +2026-04-21 23:00:00,103541.26129717015,1.6469915662221755,0.008695615008003279 +2026-04-22 01:00:00,103533.14481842199,1.6547013438776614,-7.838883404036459e-05 +2026-04-22 02:00:00,103936.77511292418,1.2712961847946034,0.0038985611343119336 +2026-04-22 03:00:00,103976.9187780839,1.2331640412553746,0.0003862315827684946 +2026-04-22 04:00:00,104248.05304112993,0.9756157930887192,0.0026076389474928054 +2026-04-22 05:00:00,104324.61117851106,0.902893836284371,0.0007343843376233034 +2026-04-22 06:00:00,104377.47797251922,0.8526760953392551,0.0005067528496962623 +2026-04-22 07:00:00,104299.16492081416,0.9270650310961457,-0.0007502868743933241 +2026-04-22 08:00:00,104586.31277706598,0.654305599987612,0.002753117500699308 +2026-04-22 09:00:00,104570.2991838602,0.6695167829294764,-0.00015311366067478555 +2026-04-22 10:00:00,104431.22222368934,0.8016248649178749,-0.0013299852946421092 +2026-04-22 11:00:00,104354.44472201772,0.874555194091289,-0.0007351968121867134 +2026-04-22 12:00:00,104312.54614253409,0.9143543165830462,-0.00040150258664347374 +2026-04-22 13:00:00,104401.16931589217,0.8301718794800369,0.0008495926581734763 +2026-04-22 14:00:00,104208.1287402607,1.0135395642588818,-0.001849026949567768 +2026-04-22 15:00:00,104073.43906589963,1.141480198865959,-0.0012925064099057494 +2026-04-22 16:00:00,104263.18917717377,0.9612380996230914,0.0018232328342104114 +2026-04-22 17:00:00,103890.48924763071,1.3152627546677234,-0.00357460703518028 +2026-04-22 18:00:00,103708.41688652377,1.4882118209932096,-0.001752541184717666 +2026-04-22 19:00:00,103627.06125210962,1.5654908815090518,-0.0007844651076216165 +2026-04-22 20:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-22 21:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-22 22:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-22 23:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 01:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 02:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 03:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 04:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 05:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 06:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 07:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 08:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 09:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 10:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 11:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 12:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 13:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 14:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 15:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 16:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 17:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 18:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 19:00:00,103627.06125210962,1.5654908815090518,0.0 +2026-04-23 20:00:00,103523.53771439522,1.6638270544546003,-0.000999000999001027 +2026-04-23 21:00:00,103337.79066032215,1.840266681970491,-0.0017942494834896209 +2026-04-23 22:00:00,103229.20007486404,1.943416002672092,-0.0010508313054132447 +2026-04-23 23:00:00,103190.9515690549,1.9797479496398718,-0.0003705202189051352 +2026-04-24 01:00:00,103168.3102121679,2.0012547918104393,-0.0002194122308471331 +2026-04-24 02:00:00,103110.81853137579,2.055865675384571,-0.0005572610491911513 +2026-04-24 03:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 04:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 05:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 06:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 07:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 08:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 09:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 10:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 11:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 12:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 13:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 14:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 15:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 16:00:00,103110.81853137579,2.055865675384571,0.0 +2026-04-24 17:00:00,103007.81072065514,2.1537119634211455,-0.0009990009990009493 +2026-04-24 18:00:00,103026.55359945552,2.1359082541207806,0.00018195589896787006 +2026-04-24 19:00:00,103019.36156456699,2.142739910015117,-6.980758491149189e-05 +2026-04-24 20:00:00,102944.39004936542,2.21395474721659,-0.0007277419900780735 +2026-04-24 21:00:00,102928.26245597904,2.2292742180099263,-0.00015666315938776403 +2026-04-24 22:00:00,102786.38322226907,2.3640441570162256,-0.0013784283376070084 +2026-04-24 23:00:00,102590.23681621843,2.5503620450433546,-0.001908291739641099 +2026-04-27 01:00:00,102096.38375387313,3.019469083120565,-0.0048138407481210154 +2026-04-27 02:00:00,101997.22084859197,3.113663126512063,-0.0009712675575289453 +2026-04-27 03:00:00,102386.46249437692,2.743925628715996,0.0038161985448873148 +2026-04-27 04:00:00,102489.98420868142,2.6455911878127925,0.0010110879093042844 +2026-04-27 05:00:00,102829.09955069788,2.323468261401424,0.003308765677297646 +2026-04-27 06:00:00,102960.29970230063,2.1988422962988428,0.001275904896337894 +2026-04-27 07:00:00,102978.82464064987,2.1812456068740516,0.00017992311990929229 +2026-04-27 08:00:00,102549.47977244561,2.589076831976638,-0.004169253918972883 +2026-04-27 09:00:00,102549.47977244561,2.589076831976638,0.0 +2026-04-27 10:00:00,102549.47977244561,2.589076831976638,0.0 +2026-04-27 11:00:00,102549.47977244561,2.589076831976638,0.0 +2026-04-27 12:00:00,102549.47977244561,2.589076831976638,0.0 +2026-04-27 13:00:00,102447.03273970592,2.6863904415350968,-0.000999000999000935 +2026-04-27 14:00:00,102360.22575786916,2.768847692970319,-0.000847335247447406 +2026-04-27 15:00:00,102238.60895875453,2.884370508890833,-0.0011881255459744455 +2026-04-27 16:00:00,102172.25274456853,2.9474017411909537,-0.0006490328346776676 +2026-04-27 17:00:00,101677.77493890889,3.4171022179002106,-0.004839648655842395 +2026-04-27 18:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-27 19:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-27 20:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-27 21:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-27 22:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-27 23:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-28 01:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-28 02:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-28 03:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-28 04:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-28 05:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-28 06:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-28 07:00:00,101677.77493890889,3.4171022179002106,0.0 +2026-04-28 08:00:00,101576.19874016872,3.5135886292709353,-0.0009990009990009509 +2026-04-28 09:00:00,101508.43917130886,3.5779528968494083,-0.0006670811637005166 +2026-04-28 10:00:00,101598.785263122,3.4921338734114538,0.0008900352773691553 +2026-04-28 11:00:00,101522.6928022988,3.5644134878118607,-0.0007489504980412545 +2026-04-28 12:00:00,100977.76552522196,4.082035510247077,-0.005367541601147374 +2026-04-28 13:00:00,100942.46037769305,4.115571584940052,-0.0003496328854701373 +2026-04-28 14:00:00,100050.84093761479,4.962513694888384,-0.00883294737162253 +2026-04-28 15:00:00,100735.67308502363,4.311997165284692,0.0068448414924954125 +2026-04-28 16:00:00,100440.73256684723,4.5921587830615,-0.0029278656621221323 +2026-04-28 17:00:00,100313.10774782965,4.713388568597623,-0.0012706480304954329 +2026-04-28 18:00:00,100576.25170456695,4.463430247904569,0.0026232260433880846 +2026-04-28 19:00:00,100704.53438347638,4.341575566566712,0.0012754768321078735 +2026-04-28 20:00:00,100757.82103471569,4.290959006626364,0.0005291385493764986 +2026-04-28 21:00:00,100748.17242296865,4.300124145051776,-9.576042482805997e-05 +2026-04-28 22:00:00,100777.11825820977,4.272628729775527,0.0002873087872958526 +2026-04-28 23:00:00,100810.44982606315,4.240967342487748,0.0003307453956758509 +2026-04-29 01:00:00,100744.66383687881,4.3034569226610255,-0.0006525711302533235 +2026-04-29 02:00:00,100778.21469136282,4.271587236772655,0.0003330286012799274 +2026-04-29 03:00:00,100879.08654144546,4.17576988050698,0.0010009291233384137 +2026-04-29 04:00:00,100540.5079837768,4.497382919798711,-0.0033562809624525956 +2026-04-29 05:00:00,100732.82235882564,4.314705047092209,0.0019128048873581361 +2026-04-29 06:00:00,100776.02182505668,4.273670222778427,0.0004288519394121925 +2026-04-29 07:00:00,100978.86195837503,4.080994017244191,0.0020127817078398427 +2026-04-29 08:00:00,100674.4921150822,4.3701124748458335,-0.0030141936380535467 +2026-04-29 09:00:00,100534.14867148898,4.503423579215465,-0.0013940318013504387 +2026-04-29 10:00:00,100166.6242785792,4.8525320337834446,-0.003655716965493199 +2026-04-29 11:00:00,100184.75402005189,4.835310738682086,0.00018099583172799354 +2026-04-29 12:00:00,100184.75402005189,4.835310738682086,0.0 +2026-04-29 13:00:00,100184.75402005189,4.835310738682086,0.0 +2026-04-29 14:00:00,100184.75402005189,4.835310738682086,0.0 +2026-04-29 15:00:00,100184.75402005189,4.835310738682086,0.0 +2026-04-29 16:00:00,100084.66935070118,4.930380358323766,-0.0009990009990010335 +2026-04-29 17:00:00,100473.33392541122,4.561191015684262,0.0038833577333222025 +2026-04-29 18:00:00,100657.2634593429,4.38647783588164,0.0018306303448457136 +2026-04-29 19:00:00,100608.1270495561,4.433152114962477,-0.000488155629291857 +2026-04-29 20:00:00,100655.71411308835,4.387949547384192,0.0004729942294702508 +2026-04-29 21:00:00,100445.00302247108,4.58810231173088,-0.0020933842899424206 +2026-04-29 22:00:00,100639.99931536375,4.402876906910047,0.0019413239785462022 +2026-04-29 23:00:00,100563.19600817446,4.475831748536418,-0.0007631489240040283 +2026-04-30 01:00:00,100755.53627891651,4.29312927771994,0.0019126308468399482 +2026-04-30 02:00:00,100968.46072132599,4.0908740683696045,0.0021132778433142515 +2026-04-30 03:00:00,101151.94758489923,3.916581377567703,0.0018172690983144482 +2026-04-30 04:00:00,101353.5839331685,3.72504863773594,0.0019934005531631557 +2026-04-30 05:00:00,101038.18130278023,4.024647050754897,-0.0031119040703705583 +2026-04-30 06:00:00,100840.52898773694,4.212395389580085,-0.0019562141013899324 +2026-04-30 07:00:00,100532.87308862135,4.50463524508631,-0.0030509151648044634 +2026-04-30 08:00:00,100987.05287638046,4.0732135303390065,0.004517724141423367 +2026-04-30 09:00:00,101583.99385473637,3.506184112856809,0.005911064451862285 +2026-04-30 10:00:00,101782.53151049654,3.3175947960301633,0.001954418685724015 +2026-04-30 11:00:00,102178.50014612084,2.941467384878634,0.0038903398230320948 +2026-04-30 12:00:00,102373.05376865508,2.7566624690585217,0.001904056354869315 +2026-04-30 13:00:00,102695.7604599576,2.450125987527553,0.0031522620398897055 +2026-04-30 14:00:00,102483.94269344422,2.651329974376072,-0.0020625755685014366 +2026-04-30 15:00:00,102691.99776191085,2.453700144033753,0.002030123578373481 +2026-04-30 16:00:00,102060.30716041745,3.0537379480731097,-0.006151312811714574 +2026-04-30 17:00:00,102251.76209044261,2.8718764552580875,0.0018758999982651466 +2026-04-30 18:00:00,102155.03861712353,2.963753301917249,-0.0009459345378667658 +2026-04-30 19:00:00,101984.61052912426,3.125641567197665,-0.001668327772240745 +2026-04-30 20:00:00,102187.79622364807,2.9326371158633493,0.0019923172081515745 +2026-04-30 21:00:00,102313.0719350865,2.8136387286572226,0.0012259361300272214 +2026-04-30 22:00:00,102192.66559759091,2.928011736855332,-0.001176842169023921 +2026-04-30 23:00:00,102295.80779110736,2.8300377996856154,0.0010092915466419334 +2026-05-01 01:00:00,102487.92672667016,2.6475455733695252,0.0018780724226267092 +2026-05-01 02:00:00,102402.04867713289,2.729120439510808,-0.0008379333281500535 +2026-05-01 03:00:00,102252.42609598026,2.8712457217570058,-0.0014611287868309566 +2026-05-01 04:00:00,102278.5436471282,2.8464368707140353,0.00025542231265419826 +2026-05-01 05:00:00,102238.03930933097,2.884911614280682,-0.00039601989188442775 +2026-05-01 06:00:00,102406.03271035882,2.7253360385042753,0.0016431594557439818 +2026-05-01 07:00:00,101917.56190595354,3.1893305132039953,-0.004769941686803294 +2026-05-01 08:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 09:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 10:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 11:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 12:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 13:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 14:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 15:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 16:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 17:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 18:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 19:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 20:00:00,101917.56190595354,3.1893305132039953,0.0 +2026-05-01 21:00:00,101815.74615979375,3.2860444687352546,-0.0009990009990009379 +2026-05-01 22:00:00,101520.7110196555,3.5662959671499377,-0.002897735873537776 +2026-05-01 23:00:00,101622.65226583759,3.469462800160393,0.0010041423583248283 +2026-05-04 01:00:00,101696.85140182757,3.3989816397382016,0.0007301436671411043 +2026-05-04 02:00:00,101588.52506679174,3.5018799510964254,-0.0010651886812877798 +2026-05-04 03:00:00,101569.14962475284,3.5202845271116905,-0.00019072471055330852 +2026-05-04 04:00:00,101569.36980023053,3.5200753842024493,2.1677396975763177e-06 +2026-05-04 05:00:00,101409.96275436481,3.671494850510066,-0.0015694401390817792 +2026-05-04 06:00:00,101613.18472029586,3.4784559452587565,0.002003964506162916 +2026-05-04 07:00:00,101411.5039827088,3.670030850145224,-0.001984789061992393 +2026-05-04 08:00:00,101260.46360499626,3.813502885900781,-0.001489381103531408 +2026-05-04 09:00:00,100927.5902081353,4.129696638987126,-0.0032872987641006145 +2026-05-04 10:00:00,100927.5902081353,4.129696638987126,0.0 +2026-05-04 11:00:00,100927.5902081353,4.129696638987126,0.0 +2026-05-04 12:00:00,100927.5902081353,4.129696638987126,0.0 +2026-05-04 13:00:00,100927.5902081353,4.129696638987126,0.0 +2026-05-04 14:00:00,100927.5902081353,4.129696638987126,0.0 +2026-05-04 15:00:00,100927.5902081353,4.129696638987126,0.0 +2026-05-04 16:00:00,100927.5902081353,4.129696638987126,0.0 +2026-05-04 17:00:00,100927.5902081353,4.129696638987126,0.0 +2026-05-04 18:00:00,100826.7634446906,4.225471167819314,-0.000999000999001058 +2026-05-04 19:00:00,100584.59082620963,4.455508983560501,-0.002401868414766806 +2026-05-04 20:00:00,100845.27203339589,4.207890008789353,0.002591661456740001 +2026-05-04 21:00:00,100711.47500661082,4.33498272466846,-0.0013267555740318028 +2026-05-04 22:00:00,100743.80928808388,4.304268651664342,0.00032105856329613184 +2026-05-04 23:00:00,100876.937329735,4.177811399364652,0.0013214513387162644 +2026-05-05 01:00:00,100738.23441196783,4.309564181492644,-0.0013749715389731602 +2026-05-05 02:00:00,100771.01468353016,4.278426466102276,0.00032540049717638605 +2026-05-05 03:00:00,101020.32314343967,4.041610372180857,0.0024740096216403678 +2026-05-05 04:00:00,101262.272766876,3.8117843776327947,0.002395058894167126 +2026-05-05 05:00:00,101219.90370839406,3.8520304043278504,-0.00041840912043800035 +2026-05-05 06:00:00,101121.3398986624,3.9456553716921325,-0.0009737591730537855 +2026-05-05 07:00:00,101099.4863842875,3.9664138486190486,-0.0002161117959551255 +2026-05-05 08:00:00,101367.08043785764,3.7122284168608224,0.0026468389023559464 +2026-05-05 09:00:00,101421.2682337056,3.6607558669297746,0.000534569957168485 +2026-05-05 10:00:00,101364.1815022773,3.7149820923715424,-0.0005628674579060114 +2026-05-05 11:00:00,101649.83815446342,3.4436391439696337,0.002818122219826798 +2026-05-05 12:00:00,101603.67818022256,3.4874861309479366,-0.0004541077003065658 +2026-05-05 13:00:00,101324.04239424177,3.7531099071352796,-0.002752221090704788 +2026-05-05 14:00:00,101743.27307816832,3.3548860640473896,0.00413752426393892 +2026-05-05 15:00:00,101789.65604745381,3.3108272558759615,0.0004558824174042138 +2026-05-05 16:00:00,102119.465718479,2.997543711233957,0.0032401098877024325 +2026-05-05 17:00:00,102185.69524673761,2.9346328168737923,0.0006485494983022448 +2026-05-05 18:00:00,101878.85406531052,3.2260987786232294,-0.003002780190379838 +2026-05-05 19:00:00,101714.72971245418,3.38199917676826,-0.0016109756471261623 +2026-05-05 20:00:00,101685.74035665074,3.409535931875407,-0.0002850064674545235 +2026-05-05 21:00:00,101709.82382147205,3.3866592430171734,0.00023684210526309698 +2026-05-05 22:00:00,101568.44496316917,3.5209538794627604,-0.0013900216615362316 +2026-05-05 23:00:00,101624.6397144189,3.4675749387935335,0.000553269780492411 +2026-05-06 01:00:00,101978.75584531005,3.131202884100142,0.0034845499269298844 +2026-05-06 02:00:00,102445.26147870065,2.6880729480683105,0.00457453740755801 +2026-05-06 03:00:00,102894.17703997485,2.261651679571917,0.00438200415318904 +2026-05-06 04:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 05:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 06:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 07:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 08:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 09:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 10:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 11:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 12:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 13:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 14:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 15:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 16:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 17:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 18:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 19:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 20:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 21:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 22:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-06 23:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 01:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 02:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 03:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 04:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 05:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 06:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 07:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 08:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 09:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 10:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 11:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 12:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 13:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 14:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 15:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 16:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 17:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 18:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 19:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 20:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 21:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 22:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-07 23:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 01:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 02:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 03:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 04:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 05:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 06:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 07:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 08:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 09:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 10:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 11:00:00,102894.17703997485,2.261651679571917,0.0 +2026-05-08 12:00:00,102791.38565432053,2.3592923871847282,-0.0009990009990009589 +2026-05-08 13:00:00,102963.50596790822,2.195796690475683,0.0016744624317694588 +2026-05-08 14:00:00,102917.91262755355,2.239105436498744,-0.0004428106825429641 +2026-05-08 15:00:00,103064.50939654077,2.0998543488074004,0.0014244048022790169 +2026-05-08 16:00:00,103577.59808799606,1.6124755418876833,0.004978325657003555 +2026-05-08 17:00:00,102879.30008572209,2.2757831783460247,-0.006741786015164343 +2026-05-08 18:00:00,102684.49217693403,2.4608296386263233,-0.001893557874380383 +2026-05-08 19:00:00,102802.5113019669,2.3487242242795734,0.001149337378321037 +2026-05-08 20:00:00,102986.62986301631,2.1738314891434314,0.0017909928339064933 +2026-05-08 21:00:00,103009.31745821671,2.1522807255721506,0.00022029651063037783 +2026-05-08 22:00:00,103053.60189884827,2.1102152928320566,0.00042990713582311755 +2026-05-08 23:00:00,102871.22853742965,2.28345027692426,-0.0017696941985359033 +2026-05-11 01:00:00,102352.03164726656,2.776631212497789,-0.005047056378588696 +2026-05-11 02:00:00,102246.19270675695,2.877166767871097,-0.0010340678031126908 +2026-05-11 03:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 04:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 05:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 06:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 07:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 08:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 09:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 10:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 11:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 12:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 13:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 14:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 15:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 16:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 17:00:00,102246.19270675695,2.877166767871097,0.0 +2026-05-11 18:00:00,102144.04865809885,2.9741925752958,-0.0009990009990009882 +2026-05-11 19:00:00,101805.81618416,3.295476872445801,-0.003311328250469137 +2026-05-11 20:00:00,101960.36273893996,3.1486743473919456,0.0015180523134395785 +2026-05-11 21:00:00,101987.99117331405,3.1224303205666644,0.00027097230366691124 +2026-05-11 22:00:00,102174.2672581955,2.945488170955656,0.0018264511609499287 +2026-05-11 23:00:00,102195.85197255024,2.9249850249984144,0.00021125391876017737 +2026-05-12 01:00:00,102548.33035796323,2.590168651516629,0.0034490478684758 +2026-05-12 02:00:00,102905.12568624716,2.251251648843406,0.003479289492461478 +2026-05-12 03:00:00,102512.7155792779,2.6239988423460896,-0.0038133193497640674 +2026-05-12 04:00:00,102036.9884748993,3.075888179243734,-0.004640664347738263 +2026-05-12 05:00:00,102096.9939808055,3.0188894334825904,0.0005880760183446692 +2026-05-12 06:00:00,101884.81623869835,3.2204353582423053,-0.0020781977395637145 +2026-05-12 07:00:00,101754.22871685216,3.344479391283608,-0.0012817172044580628 +2026-05-12 08:00:00,101757.68227114892,3.3411988879304437,3.3940155021707036e-05 +2026-05-12 09:00:00,101384.91425424245,3.6952882186120535,-0.0036632911499809152 +2026-05-12 10:00:00,101445.99899586638,3.637264315553058,0.000602503262672243 +2026-05-12 11:00:00,101419.37705672269,3.662552280682321,-0.00026242473244089773 +2026-05-12 12:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 13:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 14:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 15:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 16:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 17:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 18:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 19:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 20:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 21:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 22:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-12 23:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-13 01:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-13 02:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-13 03:00:00,101419.37705672269,3.662552280682321,0.0 +2026-05-13 04:00:00,101318.05899772496,3.7587934871951285,-0.0009990009990010224 +2026-05-13 05:00:00,101091.40438524334,3.974090874282,-0.0022370603495938724 +2026-05-13 06:00:00,101177.80029741551,3.892024094603456,0.0008546316345841808 +2026-05-13 07:00:00,101254.28545657043,3.8193714592271797,0.0007559480333639643 +2026-05-13 08:00:00,101349.51486599713,3.728913811800939,0.0009404975700267374 +2026-05-13 09:00:00,101301.25380782613,3.774756601446823,-0.0004761844024099505 +2026-05-13 10:00:00,101325.16888575908,3.752039861934802,0.00023607879501983658 +2026-05-13 11:00:00,101277.76963219827,3.7970640303370153,-0.00046779348193589856 +2026-05-13 12:00:00,101282.72500870691,3.79235695818586,4.8928570668980234e-05 +2026-05-13 13:00:00,101137.72638304137,3.930089982434414,-0.001431622477111218 +2026-05-13 14:00:00,101105.89455250802,3.9603267813461436,-0.0003147374542788797 +2026-05-13 15:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-13 16:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-13 17:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-13 18:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-13 19:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-13 20:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-13 21:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-13 22:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-13 23:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-14 01:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-14 02:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-14 03:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-14 04:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-14 05:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-14 06:00:00,101105.89455250802,3.9603267813461436,0.0 +2026-05-14 07:00:00,101004.88966284518,4.056270510835307,-0.0009990009990009795 +2026-05-14 08:00:00,100728.67236728487,4.318647090562563,-0.0027346923152167836 +2026-05-14 09:00:00,100955.9180116883,4.102788287023332,0.002256017468142847 +2026-05-14 10:00:00,100803.84814756956,4.247238223607246,-0.0015062996515087122 +2026-05-14 11:00:00,100808.14390644297,4.243157716924082,4.261502861601823e-05 +2026-05-14 12:00:00,100942.17158329338,4.115845908409456,0.0013295322347646724 +2026-05-14 13:00:00,100889.11896120675,4.166240165946504,-0.0005255744081437119 +2026-05-14 14:00:00,100824.89736604926,4.227243740859763,-0.0006365562096164691 +2026-05-14 15:00:00,101057.9422849318,4.005876253298258,0.0023113826541916946 +2026-05-14 16:00:00,100512.16612006498,4.524304627393878,-0.00540062614107077 +2026-05-14 17:00:00,100511.3069682903,4.525120728730511,-8.547739123007283e-06 +2026-05-14 18:00:00,100645.97900897171,4.39719684421342,0.0013398695603858755 +2026-05-14 19:00:00,100389.76749206075,4.640570096266545,-0.0025456706709377943 +2026-05-14 20:00:00,100389.76749206075,4.640570096266545,0.0 +2026-05-14 21:00:00,100389.76749206075,4.640570096266545,0.0 +2026-05-14 22:00:00,100389.76749206075,4.640570096266545,0.0 +2026-05-14 23:00:00,100389.76749206075,4.640570096266545,0.0 +2026-05-15 01:00:00,100389.76749206075,4.640570096266545,0.0 +2026-05-15 02:00:00,100389.76749206075,4.640570096266545,0.0 +2026-05-15 03:00:00,100289.4780140467,4.735834262004543,-0.0009990009990010183 +2026-05-15 04:00:00,99935.7847426878,5.071804645841112,-0.003526723624081143 +2026-05-15 05:00:00,100023.07095723628,4.988892175120861,0.0008734230163221879 +2026-05-15 06:00:00,99968.05681704941,5.041149662870092,-0.0005500145082567568 +2026-05-15 07:00:00,99767.06027337453,5.232074657481448,-0.002010607688841218 +2026-05-15 08:00:00,98741.717991309,6.2060390157682175,-0.010277362881656234 +2026-05-15 09:00:00,98984.08343567557,5.975818036274573,0.0024545394722411576 +2026-05-15 10:00:00,99000.7609506141,5.959976199437213,0.00016848683505135017 +2026-05-15 11:00:00,98644.68517712114,6.298209702821961,-0.0035966973392314726 +2026-05-15 12:00:00,98457.98364625072,6.475555980144149,-0.0018926669037990509 +2026-05-15 13:00:00,98898.74654105498,6.056878863728107,0.00447665977385714 +2026-05-15 14:00:00,98593.13649458383,6.347175380319273,-0.0030901306352177447 +2026-05-15 15:00:00,98461.01592169408,6.472675646173731,-0.001340058523211761 +2026-05-15 16:00:00,98190.7102250279,6.729436845823458,-0.002745306801233457 +2026-05-15 17:00:00,98827.27147703269,6.124772450173933,0.006482907095243044 +2026-05-15 18:00:00,98331.27785093844,6.59591279247997,-0.005018793078887307 +2026-05-15 19:00:00,98427.66089181701,6.504359319848452,0.0009801870064648271 +2026-05-15 20:00:00,98756.87936852586,6.1916373459160585,0.0033447759880293744 +2026-05-15 21:00:00,98750.16504432981,6.198015228279162,-6.798842003693129e-05 +2026-05-15 22:00:00,98458.85001066313,6.474733027581159,-0.0029500207269113304 +2026-05-15 23:00:00,98302.90441643262,6.622864488917555,-0.001583865688189713 +2026-05-18 01:00:00,98467.08047258084,6.466914978232855,0.0016701038196464339 +2026-05-18 02:00:00,98271.93188868962,6.652285043044085,-0.0019818662537228427 +2026-05-18 03:00:00,97211.84712229742,7.659251006789218,-0.010787258844091282 +2026-05-18 04:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 05:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 06:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 07:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 08:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 09:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 10:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 11:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 12:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 13:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 14:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 15:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 16:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 17:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 18:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 19:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 20:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 21:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 22:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-18 23:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 01:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 02:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 03:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 04:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 05:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 06:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 07:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 08:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 09:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 10:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 11:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 12:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 13:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 14:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 15:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 16:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 17:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 18:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 19:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 20:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 21:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 22:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-19 23:00:00,97211.84712229742,7.659251006789218,0.0 +2026-05-20 01:00:00,97211.84712229742,7.659251006789218,0.0 diff --git a/backtest_output/multi_strategy_metrics.csv b/backtest_output/multi_strategy_metrics.csv new file mode 100644 index 0000000..407bb57 --- /dev/null +++ b/backtest_output/multi_strategy_metrics.csv @@ -0,0 +1,43 @@ +metric,value +Start Value,100000.0 +End Value,97211.84712229742 +Total Return [%],-2.7881528777025815 +Total Fees Paid,4296.098928082393 +Max Drawdown [%],7.659251006789218 +Max Drawdown Duration,546.0 +Total Trades,21.0 +Total Closed Trades,21.0 +Total Open Trades,0.0 +Open Trade PnL,-0.0 +Win Rate [%],33.33333333333333 +Best Trade [%],3.196952880053045 +Worst Trade [%],-3.1687475423077114 +Avg Winning Trade [%],1.4665268393501272 +Avg Losing Trade [%],-0.9214541360329661 +Avg Winning Trade Duration,23.714285714285715 +Avg Losing Trade Duration,14.0 +Profit Factor,0.788080414612577 +Expectancy,-132.7691846525046 +SQN,-0.4321184623041379 +Sharpe Ratio,-0.2923100943334959 +Sortino Ratio,-0.42150656548706195 +Calmar Ratio,-0.16401445694169173 +Omega Ratio,0.9352887502326621 +total_trades,21.0 +total_closed_trades,21.0 +total_open_trades,0.0 +winning_trades,7.0 +losing_trades,14.0 +max_consecutive_wins,3.0 +max_consecutive_losses,6.0 +avg_holding_period,17.238095238095237 +avg_winning_duration,23.714285714285715 +avg_losing_duration,14.0 +start_value,100000.0 +end_value,97211.84712229742 +total_fees_paid,4296.098928082393 +open_trade_pnl,-0.0 +exposure_pct,44.30844553243574 +payoff_ratio,1.591535359170237 +recovery_factor,-0.3640242205446905 +omega_ratio,0.9352887502326621 diff --git a/backtest_output/multi_strategy_trades.csv b/backtest_output/multi_strategy_trades.csv new file mode 100644 index 0000000..8adee66 --- /dev/null +++ b/backtest_output/multi_strategy_trades.csv @@ -0,0 +1,22 @@ +trade_id,symbol,direction,entry_idx,exit_idx,entry_time,exit_time,entry_price,exit_price,size,pnl,return_pct,fees,exit_reason +0,SMA_Cross,Long,64,88,2026-04-02 08:00:00,2026-04-06 09:00:00,4594.24,4666.08,21.74464109408736,1360.7726813828401,1.362133454064223,201.36233481639906,Signal +1,SMA_Cross,Long,91,103,2026-04-06 12:00:00,2026-04-07 01:00:00,4693.64,4658.49,21.573770712754843,-960.0787488492206,-0.9481368404905478,201.760708295876,Signal +2,SMA_Cross,Long,116,126,2026-04-07 14:00:00,2026-04-08 01:00:00,4659.81,4818.26,21.524567211752117,3206.5563199494277,3.196952880053045,204.0113547526914,Signal +3,SMA_Cross,Long,163,179,2026-04-09 15:00:00,2026-04-10 08:00:00,4745.67,4765.93,21.810144090502938,234.42415274236654,0.2264885674730906,207.44936653122775,Signal +4,SMA_Cross,Long,190,237,2026-04-10 19:00:00,2026-04-14 20:00:00,4763.89,4835.21,21.775888290610546,1344.027423595938,1.2955987648749172,209.0289292903997,Signal +5,SMA_Cross,Long,269,280,2026-04-16 06:00:00,2026-04-16 17:00:00,4824.21,4788.8,21.781933458040537,-980.6882079006906,-0.933272183424848,209.3899441514783,Signal +6,SMA_Cross,Long,301,302,2026-04-17 15:00:00,2026-04-17 16:00:00,4846.36,4880.45,21.480226955532085,523.3268505607529,0.5027111068925987,208.93408635333904,Signal +7,SMA_Cross,Long,327,341,2026-04-20 18:00:00,2026-04-21 09:00:00,4799.78,4778.83,21.797606714209135,-665.4506343114682,-0.6360418602519246,208.79077364879075,Signal +8,SMA_Cross,Long,349,374,2026-04-21 17:00:00,2026-04-22 19:00:00,4739.1,4728.7,21.936429049031187,-435.8285850603538,-0.41923149965184414,207.6897229504175,Signal +9,SMA_Cross,Long,398,403,2026-04-23 20:00:00,2026-04-24 02:00:00,4709.49,4695.41,21.981899890305584,-516.242720733836,-0.49867183070778215,206.73757027833497,Signal +10,SMA_Cross,Long,418,432,2026-04-24 17:00:00,2026-04-27 08:00:00,4726.42,4710.1,21.794045116738495,-561.338758930171,-0.5449477617308599,205.65994262500516,Signal +11,SMA_Cross,Long,437,441,2026-04-27 13:00:00,2026-04-27 17:00:00,4708.88,4678.2,21.756135798683747,-871.7048335367319,-0.8508834372504775,204.22658723310823,Signal +12,SMA_Cross,Long,455,481,2026-04-28 08:00:00,2026-04-29 11:00:00,4632.12,4573.24,21.928663061442435,-1493.0209188570127,-1.4698531126136651,201.86123779927973,Signal +13,SMA_Cross,Long,486,523,2026-04-29 16:00:00,2026-05-01 07:00:00,4521.86,4609.28,22.133517921983696,1732.8078859016741,1.731341969897345,202.1042508381422,Signal +14,SMA_Cross,Long,537,548,2026-05-01 21:00:00,2026-05-04 09:00:00,4624.3,4588.55,22.017547771510014,-989.9716978182389,-0.9723168912051552,202.84436498675603,Signal +15,SMA_Cross,Long,557,588,2026-05-04 18:00:00,2026-05-06 03:00:00,4521.48,4618.81,22.299504464177797,1966.586831839564,1.9504611321956713,203.8239376588797,Signal +16,SMA_Cross,Long,643,656,2026-05-08 12:00:00,2026-05-11 02:00:00,4711.96,4691.66,21.81499538500338,-647.9843332178981,-0.6303877791831888,205.1399269023255,Signal +17,SMA_Cross,Long,672,688,2026-05-11 18:00:00,2026-05-12 11:00:00,4732.24,4703.37,21.58471435474508,-826.8156500342644,-0.8094604246614688,203.66494661277625,Signal +18,SMA_Cross,Long,704,714,2026-05-13 04:00:00,2026-05-13 14:00:00,4702.6,4697.45,21.545115254906854,-313.48250421466923,-0.3094043720495161,202.52516065188718,Signal +19,SMA_Cross,Long,730,742,2026-05-14 07:00:00,2026-05-14 19:00:00,4702.54,4678.58,21.4787943670538,-716.1270604472656,-0.7090023689325352,201.49514741265574,Signal +20,SMA_Cross,Long,749,772,2026-05-15 03:00:00,2026-05-18 03:00:00,4630.36,4492.7587,21.65911030979162,-3177.9203697633384,-3.1687475423077114,197.59863429262276,StopLoss diff --git a/backtest_output/sma_cross_curves.csv b/backtest_output/sma_cross_curves.csv new file mode 100644 index 0000000..c69d52e --- /dev/null +++ b/backtest_output/sma_cross_curves.csv @@ -0,0 +1,818 @@ +time,equity,drawdown,returns +2026-03-30 13:00:00,100000.0,0.0,0.0 +2026-03-30 14:00:00,100000.0,0.0,0.0 +2026-03-30 15:00:00,100000.0,0.0,0.0 +2026-03-30 16:00:00,100000.0,0.0,0.0 +2026-03-30 17:00:00,100000.0,0.0,0.0 +2026-03-30 18:00:00,100000.0,0.0,0.0 +2026-03-30 19:00:00,100000.0,0.0,0.0 +2026-03-30 20:00:00,100000.0,0.0,0.0 +2026-03-30 21:00:00,100000.0,0.0,0.0 +2026-03-30 22:00:00,100000.0,0.0,0.0 +2026-03-30 23:00:00,100000.0,0.0,0.0 +2026-03-31 01:00:00,100000.0,0.0,0.0 +2026-03-31 02:00:00,100000.0,0.0,0.0 +2026-03-31 03:00:00,100000.0,0.0,0.0 +2026-03-31 04:00:00,100000.0,0.0,0.0 +2026-03-31 05:00:00,100000.0,0.0,0.0 +2026-03-31 06:00:00,100000.0,0.0,0.0 +2026-03-31 07:00:00,100000.0,0.0,0.0 +2026-03-31 08:00:00,100000.0,0.0,0.0 +2026-03-31 09:00:00,100000.0,0.0,0.0 +2026-03-31 10:00:00,100000.0,0.0,0.0 +2026-03-31 11:00:00,100000.0,0.0,0.0 +2026-03-31 12:00:00,100000.0,0.0,0.0 +2026-03-31 13:00:00,100000.0,0.0,0.0 +2026-03-31 14:00:00,100000.0,0.0,0.0 +2026-03-31 15:00:00,100000.0,0.0,0.0 +2026-03-31 16:00:00,100000.0,0.0,0.0 +2026-03-31 17:00:00,100000.0,0.0,0.0 +2026-03-31 18:00:00,100000.0,0.0,0.0 +2026-03-31 19:00:00,100000.0,0.0,0.0 +2026-03-31 20:00:00,100000.0,0.0,0.0 +2026-03-31 21:00:00,100000.0,0.0,0.0 +2026-03-31 22:00:00,100000.0,0.0,0.0 +2026-03-31 23:00:00,100000.0,0.0,0.0 +2026-04-01 01:00:00,100000.0,0.0,0.0 +2026-04-01 02:00:00,100000.0,0.0,0.0 +2026-04-01 03:00:00,100000.0,0.0,0.0 +2026-04-01 04:00:00,100000.0,0.0,0.0 +2026-04-01 05:00:00,100000.0,0.0,0.0 +2026-04-01 06:00:00,100000.0,0.0,0.0 +2026-04-01 07:00:00,100000.0,0.0,0.0 +2026-04-01 08:00:00,100000.0,0.0,0.0 +2026-04-01 09:00:00,100000.0,0.0,0.0 +2026-04-01 10:00:00,100000.0,0.0,0.0 +2026-04-01 11:00:00,100000.0,0.0,0.0 +2026-04-01 12:00:00,100000.0,0.0,0.0 +2026-04-01 13:00:00,100000.0,0.0,0.0 +2026-04-01 14:00:00,100000.0,0.0,0.0 +2026-04-01 15:00:00,100000.0,0.0,0.0 +2026-04-01 16:00:00,100000.0,0.0,0.0 +2026-04-01 17:00:00,100000.0,0.0,0.0 +2026-04-01 18:00:00,100000.0,0.0,0.0 +2026-04-01 19:00:00,100000.0,0.0,0.0 +2026-04-01 20:00:00,100000.0,0.0,0.0 +2026-04-01 21:00:00,100000.0,0.0,0.0 +2026-04-01 22:00:00,100000.0,0.0,0.0 +2026-04-01 23:00:00,100000.0,0.0,0.0 +2026-04-02 01:00:00,100000.0,0.0,0.0 +2026-04-02 02:00:00,100000.0,0.0,0.0 +2026-04-02 03:00:00,100000.0,0.0,0.0 +2026-04-02 04:00:00,100000.0,0.0,0.0 +2026-04-02 05:00:00,100000.0,0.0,0.0 +2026-04-02 06:00:00,100000.0,0.0,0.0 +2026-04-02 07:00:00,100000.0,0.0,0.0 +2026-04-02 08:00:00,100000.0,0.0,0.0 +2026-04-02 09:00:00,100000.0,0.0,0.0 +2026-04-02 10:00:00,100000.0,0.0,0.0 +2026-04-02 11:00:00,100000.0,0.0,0.0 +2026-04-02 12:00:00,100000.0,0.0,0.0 +2026-04-02 13:00:00,100000.0,0.0,0.0 +2026-04-02 14:00:00,100000.0,0.0,0.0 +2026-04-02 15:00:00,100000.0,0.0,0.0 +2026-04-02 16:00:00,100000.0,0.0,0.0 +2026-04-02 17:00:00,100000.0,0.0,0.0 +2026-04-02 18:00:00,100000.0,0.0,0.0 +2026-04-02 19:00:00,100000.0,0.0,0.0 +2026-04-02 20:00:00,100000.0,0.0,0.0 +2026-04-02 21:00:00,100000.0,0.0,0.0 +2026-04-02 22:00:00,100000.0,0.0,0.0 +2026-04-02 23:00:00,99900.0999000999,0.09990009990010003,-0.0009990009990010003 +2026-04-06 01:00:00,99255.75404057198,0.7442459594280226,-0.006449902053874505 +2026-04-06 02:00:00,98508.85976838708,1.4911402316129243,-0.007524946834614744 +2026-04-06 03:00:00,98752.41224844735,1.2475877515526517,0.002472391626833468 +2026-04-06 04:00:00,98832.74183836198,1.1672581616380193,0.000813444330985397 +2026-04-06 05:00:00,99302.11446879398,0.6978855312060187,0.0047491612769343765 +2026-04-06 06:00:00,99142.73714412295,0.857262855877052,-0.0016049741289357769 +2026-04-06 07:00:00,99538.83038801048,0.4611696119895205,0.003995181647161246 +2026-04-06 08:00:00,99399.53546081808,0.6004645391819213,-0.0013994028928149729 +2026-04-06 09:00:00,99587.62463189286,0.4123753681071394,0.0018922540251601484 +2026-04-06 10:00:00,99587.62463189286,0.4123753681071394,0.0 +2026-04-06 11:00:00,99587.62463189286,0.4123753681071394,0.0 +2026-04-06 12:00:00,99488.13649539747,0.5118635046025302,-0.000999000999000932 +2026-04-06 13:00:00,99405.89457449979,0.5941054255002091,-0.0008266505313574108 +2026-04-06 14:00:00,99157.2611383839,0.8427388616161043,-0.0025011940909556094 +2026-04-06 15:00:00,99112.96072223024,0.8870392777697562,-0.0004467692597098484 +2026-04-06 16:00:00,99195.83853426889,0.8041614657311147,0.0008361955029364045 +2026-04-06 17:00:00,99262.39514035617,0.737604859643834,0.0006709616761220034 +2026-04-06 18:00:00,98947.6290255803,1.0523709744196967,-0.0031710509738434793 +2026-04-06 19:00:00,98785.90071206246,1.2140992879375407,-0.0016344839700609044 +2026-04-06 20:00:00,98640.70556820961,1.3592944317903894,-0.0014697962240184262 +2026-04-06 21:00:00,98742.23618704977,1.2577638129502302,0.0010292973702418539 +2026-04-06 22:00:00,98678.01118181268,1.3219888181873247,-0.0006504309373289018 +2026-04-06 23:00:00,98573.72503469502,1.426274965304983,-0.0010568326810469744 +2026-04-07 01:00:00,98644.34095786247,1.3556590421375294,0.0007163767336843467 +2026-04-07 02:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 03:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 04:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 05:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 06:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 07:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 08:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 09:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 10:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 11:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 12:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 13:00:00,98644.34095786247,1.3556590421375294,0.0 +2026-04-07 14:00:00,98545.79516269977,1.4542048373002325,-0.0009990009990010333 +2026-04-07 15:00:00,98373.2272493025,1.6267727506975063,-0.0017511443599634337 +2026-04-07 16:00:00,98218.21219964541,1.781787800354592,-0.0015757849365278908 +2026-04-07 17:00:00,97986.21832587486,2.0137816741251444,-0.0023620250111963453 +2026-04-07 18:00:00,98457.6078834269,1.5423921165731007,0.00481077406196383 +2026-04-07 19:00:00,98489.32992633081,1.5106700736691856,0.0003221898600408203 +2026-04-07 20:00:00,98886.91286405975,1.1130871359402517,0.004036812292522677 +2026-04-07 21:00:00,98597.81931306217,1.4021806869378344,-0.002923476349140362 +2026-04-07 22:00:00,99413.71025655062,0.5862897434493789,0.008274939031844964 +2026-04-07 23:00:00,99516.91263613131,0.4830873638686899,0.001038110129019038 +2026-04-08 01:00:00,101896.70029478235,0.0,0.02391339919629919 +2026-04-08 02:00:00,102219.84217183014,0.0,0.0031712692963850263 +2026-04-08 03:00:00,102385.13934223856,0.0,0.0016170751871300787 +2026-04-08 04:00:00,102385.13934223856,0.0,0.0 +2026-04-08 05:00:00,102385.13934223856,0.0,0.0 +2026-04-08 06:00:00,102385.13934223856,0.0,0.0 +2026-04-08 07:00:00,102385.13934223856,0.0,0.0 +2026-04-08 08:00:00,102385.13934223856,0.0,0.0 +2026-04-08 09:00:00,102385.13934223856,0.0,0.0 +2026-04-08 10:00:00,102385.13934223856,0.0,0.0 +2026-04-08 11:00:00,102385.13934223856,0.0,0.0 +2026-04-08 12:00:00,102385.13934223856,0.0,0.0 +2026-04-08 13:00:00,102385.13934223856,0.0,0.0 +2026-04-08 14:00:00,102385.13934223856,0.0,0.0 +2026-04-08 15:00:00,102385.13934223856,0.0,0.0 +2026-04-08 16:00:00,102385.13934223856,0.0,0.0 +2026-04-08 17:00:00,102385.13934223856,0.0,0.0 +2026-04-08 18:00:00,102385.13934223856,0.0,0.0 +2026-04-08 19:00:00,102385.13934223856,0.0,0.0 +2026-04-08 20:00:00,102385.13934223856,0.0,0.0 +2026-04-08 21:00:00,102385.13934223856,0.0,0.0 +2026-04-08 22:00:00,102385.13934223856,0.0,0.0 +2026-04-08 23:00:00,102385.13934223856,0.0,0.0 +2026-04-09 01:00:00,102385.13934223856,0.0,0.0 +2026-04-09 02:00:00,102385.13934223856,0.0,0.0 +2026-04-09 03:00:00,102385.13934223856,0.0,0.0 +2026-04-09 04:00:00,102385.13934223856,0.0,0.0 +2026-04-09 05:00:00,102385.13934223856,0.0,0.0 +2026-04-09 06:00:00,102385.13934223856,0.0,0.0 +2026-04-09 07:00:00,102385.13934223856,0.0,0.0 +2026-04-09 08:00:00,102385.13934223856,0.0,0.0 +2026-04-09 09:00:00,102385.13934223856,0.0,0.0 +2026-04-09 10:00:00,102385.13934223856,0.0,0.0 +2026-04-09 11:00:00,102385.13934223856,0.0,0.0 +2026-04-09 12:00:00,102385.13934223856,0.0,0.0 +2026-04-09 13:00:00,102385.13934223856,0.0,0.0 +2026-04-09 14:00:00,102385.13934223856,0.0,0.0 +2026-04-09 15:00:00,102282.85648575281,0.0999000999000954,-0.000999000999000954 +2026-04-09 16:00:00,102689.34380436562,0.0,0.003974149066412026 +2026-04-09 17:00:00,102778.57272796356,0.0,0.0008689209638726625 +2026-04-09 18:00:00,103302.0921854983,0.0,0.005093663432361562 +2026-04-09 19:00:00,103215.88066511383,0.08345573507810886,-0.0008345573507810886 +2026-04-09 20:00:00,103407.05471156641,0.0,0.001852176673014568 +2026-04-09 21:00:00,102854.22333710095,0.5346166912958433,-0.005346166912958433 +2026-04-09 22:00:00,102782.66777518182,0.6038146412023746,-0.000695698821083976 +2026-04-09 23:00:00,102710.68115566079,0.6734294462288081,-0.0007003770293109319 +2026-04-10 01:00:00,102683.52452673968,0.6996913187836856,-0.00026439926807561066 +2026-04-10 02:00:00,102673.17914429352,0.7096958416617597,-0.00010075016896666037 +2026-04-10 03:00:00,102658.52318582818,0.723868915738982,-0.0001427437874963154 +2026-04-10 04:00:00,102687.40404515696,0.6959396227044395,0.0002813293863238631 +2026-04-10 05:00:00,102439.97698165351,0.9352144615378221,-0.002409517173056996 +2026-04-10 06:00:00,102734.38932376652,0.6505024146332744,0.0028739985188178433 +2026-04-10 07:00:00,102562.39734059949,0.8168276074808655,-0.0016741422643297714 +2026-04-10 08:00:00,102616.79831866368,0.7642190323542124,0.0005304183548238776 +2026-04-10 09:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 10:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 11:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 12:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 13:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 14:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 15:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 16:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 17:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 18:00:00,102616.79831866368,0.7642190323542124,0.0 +2026-04-10 19:00:00,102514.28403462906,0.8633556766775293,-0.0009990009990009441 +2026-04-10 20:00:00,102436.60034223375,0.9384798474722513,-0.0007577840798172809 +2026-04-10 21:00:00,102546.56257717559,0.8321406472614408,0.0010734662666904082 +2026-04-10 22:00:00,102424.54968634974,0.9501334584542354,-0.0011898291640348518 +2026-04-10 23:00:00,102197.73912738952,1.169471065151248,-0.002214415974048919 +2026-04-13 01:00:00,100363.53435558254,2.94324247458084,-0.01794760615516791 +2026-04-13 02:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 03:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 04:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 05:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 06:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 07:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 08:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 09:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 10:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 11:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 12:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 13:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 14:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 15:00:00,100363.53435558254,2.94324247458084,0.0 +2026-04-13 16:00:00,100263.27108449805,3.0402022723085254,-0.0009990009990009374 +2026-04-13 17:00:00,100021.01811005366,3.2744734979227794,-0.002416168670980499 +2026-04-13 18:00:00,99924.15930924781,3.3681409958280355,-0.0009683844719443762 +2026-04-13 19:00:00,100315.40951994277,2.989781693567402,0.003915471627678179 +2026-04-13 20:00:00,100250.76633778568,3.0522950127383206,-0.0006443993247541557 +2026-04-13 21:00:00,100430.07168894925,2.8788974126773734,0.0017885683841998119 +2026-04-13 22:00:00,100488.99235989897,2.8219180594658635,0.0005866835496464779 +2026-04-13 23:00:00,100467.79787394582,2.8424142296858435,-0.00021091350858851166 +2026-04-14 01:00:00,100845.69555849029,2.476967514663796,0.0037613811842338598 +2026-04-14 02:00:00,100837.21776410904,2.48516598275178,-8.406699298666981e-05 +2026-04-14 03:00:00,101094.94271329921,2.235932552876954,0.0025558514495419445 +2026-04-14 04:00:00,100991.93751156697,2.3355439401459925,-0.0010188956931739231 +2026-04-14 05:00:00,101208.75710286756,2.125868118795731,0.002146900006505549 +2026-04-14 06:00:00,101058.70014231934,2.2709810039531027,-0.0014826479925615491 +2026-04-14 07:00:00,101187.35067205489,2.1465692507178984,0.0012730277507465519 +2026-04-14 08:00:00,100886.38897152033,2.4376148678414475,-0.002974301615129465 +2026-04-14 09:00:00,101356.69461482049,1.9828048506603306,0.004661735325197602 +2026-04-14 10:00:00,101252.41774393104,2.083646008142577,-0.0010288108869939035 +2026-04-14 11:00:00,101452.7056361882,1.8899571995638802,0.0019781047872228165 +2026-04-14 12:00:00,101274.4600093223,2.06232999111382,-0.0017569332010236743 +2026-04-14 13:00:00,101065.48237782434,2.264422229482716,-0.0020634781116454946 +2026-04-14 14:00:00,101204.51820567694,2.129967352839716,0.0013757004328423753 +2026-04-14 15:00:00,101126.31055250987,2.205598220951394,-0.0007727683956573444 +2026-04-14 16:00:00,101620.7779097966,1.7274225697195205,0.0048896014754733885 +2026-04-14 17:00:00,101817.67468430127,1.5370131483760026,0.0019375641335814542 +2026-04-14 18:00:00,101907.53930474256,1.450109386643349,0.0008826033468150904 +2026-04-14 19:00:00,101952.8955046823,1.4062475823725984,0.0004450720746391769 +2026-04-14 20:00:00,102479.79042547732,0.8967127907041863,0.005168023116820872 +2026-04-14 21:00:00,102491.23544789202,0.885644858785398,0.0001116807749819035 +2026-04-14 22:00:00,102687.70833267762,0.6956453608462829,0.0019169725482086884 +2026-04-14 23:00:00,102616.91874959413,0.7641025693809841,-0.0006893676393493209 +2026-04-15 01:00:00,102480.21431519637,0.8963028672997977,-0.0013321822177427447 +2026-04-15 02:00:00,102522.60328710265,0.8553105268598519,0.0004136307890214924 +2026-04-15 03:00:00,102485.51293668465,0.8911788247448063,-0.00036177729816453373 +2026-04-15 04:00:00,102733.27647747686,0.6515785948733591,0.0024175469653479123 +2026-04-15 05:00:00,102243.04801738076,1.1256550120612336,-0.0047718565678529345 +2026-04-15 06:00:00,102243.89579681888,1.1248351652524424,8.291805208836794e-06 +2026-04-15 07:00:00,102262.12305473856,1.1072084588632813,0.0001782723337920196 +2026-04-15 08:00:00,102155.30284553475,1.210509156771916,-0.0010445725749956651 +2026-04-15 09:00:00,101982.35584015714,1.3777579057668643,-0.001692981182182226 +2026-04-15 10:00:00,101971.75859718057,1.3880059908768474,-0.00010391251397621775 +2026-04-15 11:00:00,102125.41862034083,1.2394087567820666,0.001506888037179667 +2026-04-15 12:00:00,101593.89885076597,1.7534160177541873,-0.00520457861280188 +2026-04-15 13:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 14:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 15:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 16:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 17:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 18:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 19:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 20:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 21:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 22:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-15 23:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-16 01:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-16 02:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-16 03:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-16 04:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-16 05:00:00,101593.89885076597,1.7534160177541873,0.0 +2026-04-16 06:00:00,101492.40644432165,1.8515644533008837,-0.0009990009990009678 +2026-04-16 07:00:00,101680.27703651143,1.669883819698267,0.001851080280502002 +2026-04-16 08:00:00,101430.34393066434,1.9115821318146158,-0.0024580293556570373 +2026-04-16 09:00:00,101532.16852934279,1.813112449100544,0.0010038869507141873 +2026-04-16 10:00:00,101426.13630261978,1.9156511269680876,-0.0010443215018338186 +2026-04-16 11:00:00,101274.03054880879,2.0627453017661788,-0.0014996701970107498 +2026-04-16 12:00:00,101254.46507840157,2.081666129229833,-0.00019319336162682525 +2026-04-16 13:00:00,101346.82251397974,1.9923516856110803,0.000912131978640766 +2026-04-16 14:00:00,101451.17168948492,1.8914406058049282,0.0010296245399384488 +2026-04-16 15:00:00,101324.94284814801,2.0135104604091425,-0.0012442324640987157 +2026-04-16 16:00:00,100874.51626597748,2.44909639158851,-0.0044453672463015525 +2026-04-16 17:00:00,100646.69845313263,2.6694080651781906,-0.0022584278098955936 +2026-04-16 18:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-16 19:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-16 20:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-16 21:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-16 22:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-16 23:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 01:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 02:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 03:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 04:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 05:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 06:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 07:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 08:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 09:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 10:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 11:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 12:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 13:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 14:00:00,100646.69845313263,2.6694080651781906,0.0 +2026-04-17 15:00:00,100546.1523008318,2.7666414237544315,-0.0009990009990009522 +2026-04-17 16:00:00,101253.4085368389,2.082687859870561,0.007034145214140148 +2026-04-17 17:00:00,101103.61710169747,2.2275439681498757,-0.0014793717792417633 +2026-04-17 18:00:00,100985.15323817151,2.342104685362442,-0.001171707471225307 +2026-04-17 19:00:00,100976.23214161876,2.3507318497059475,-8.834067451187807e-05 +2026-04-17 20:00:00,100885.15396983609,2.4388091787012662,-0.0009019763349353242 +2026-04-17 21:00:00,100791.17125498973,2.5296953519014393,-0.0009315812203097001 +2026-04-17 22:00:00,100714.61579852548,2.6037284598724457,-0.000759545260869877 +2026-04-17 23:00:00,100256.94279863354,3.0463220538671067,-0.004544256027422068 +2026-04-20 01:00:00,99155.29110805046,4.111676534425439,-0.010988283303189798 +2026-04-20 02:00:00,98436.69402556034,4.806597286684075,-0.007247188470326349 +2026-04-20 03:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 04:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 05:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 06:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 07:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 08:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 09:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 10:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 11:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 12:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 13:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 14:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 15:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 16:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 17:00:00,98436.69402556034,4.806597286684075,0.0 +2026-04-20 18:00:00,98338.35566989044,4.901695591092989,-0.0009990009990010684 +2026-04-20 19:00:00,98458.41591519868,4.785591089670795,0.0012208892907592077 +2026-04-20 20:00:00,98484.23091674958,4.760626640559554,0.00026219192448856805 +2026-04-20 21:00:00,98632.56473518503,4.617180123444065,0.0015061682165222958 +2026-04-20 22:00:00,98634.40866386723,4.615396948507558,1.8694927858223442e-05 +2026-04-20 23:00:00,98766.14712416279,4.487999005820751,0.0013356237653789163 +2026-04-21 01:00:00,98911.61260909257,4.347326316384303,0.0014728273721856338 +2026-04-21 02:00:00,98791.55236378431,4.46343081780651,-0.0012138134455732968 +2026-04-21 03:00:00,98836.83105698077,4.419643966587561,0.0004583255563159835 +2026-04-21 04:00:00,98554.5050876382,4.69266786242335,-0.002856485444983557 +2026-04-21 05:00:00,98485.05044060835,4.75983411836554,-0.0007047333550920919 +2026-04-21 06:00:00,98285.70126196517,4.952615142058005,-0.002024156740046573 +2026-04-21 07:00:00,98119.54279960172,5.113299016893148,-0.001690565974806156 +2026-04-21 08:00:00,98017.92184111557,5.2115717689501535,-0.0010356852018124404 +2026-04-21 09:00:00,97811.22091881641,5.411462311114528,-0.002108807434565064 +2026-04-21 10:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 11:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 12:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 13:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 14:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 15:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 16:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 17:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 18:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 19:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 20:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 21:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 22:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-21 23:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 01:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 02:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 03:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 04:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 05:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 06:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 07:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 08:00:00,97811.22091881641,5.411462311114528,0.0 +2026-04-22 09:00:00,97713.50741140501,5.5059563547597685,-0.0009990009990009975 +2026-04-22 10:00:00,97583.54988345993,5.631632043239216,-0.001329985294642198 +2026-04-22 11:00:00,97511.80676866375,5.701011366532283,-0.0007351968121866439 +2026-04-22 12:00:00,97472.65552601784,5.738872654386494,-0.0004015025866435907 +2026-04-22 13:00:00,97555.46757852544,5.65878909264249,0.0008495926581736475 +2026-04-22 14:00:00,97375.08488989505,5.833228534065059,-0.0018490269495678568 +2026-04-22 15:00:00,97249.22696850976,5.954939689784898,-0.0012925064099056237 +2026-04-22 16:00:00,97426.5349522203,5.783473647932035,0.0018232328342101991 +2026-04-22 17:00:00,97078.27337496687,6.1202607058603755,-0.003574607035180172 +2026-04-22 18:00:00,96908.13970273595,6.284788815383922,-0.0017525411847177767 +2026-04-22 19:00:00,96832.11864849464,6.358305128611639,-0.0007844651076215556 +2026-04-22 20:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-22 21:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-22 22:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-22 23:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 01:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 02:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 03:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 04:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 05:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 06:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 07:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 08:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 09:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 10:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 11:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 12:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 13:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 14:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 15:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 16:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 17:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 18:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 19:00:00,96832.11864849464,6.358305128611639,0.0 +2026-04-23 20:00:00,96735.38326522941,6.451853275336304,-0.0009990009990010027 +2026-04-23 21:00:00,96561.81585377059,6.619701989278452,-0.0017942494834897228 +2026-04-23 22:00:00,96460.34567476391,6.717828929736921,-0.0010508313054130843 +2026-04-23 23:00:00,96424.60516636883,6.752391860181831,-0.00037052021890522386 +2026-04-24 01:00:00,96403.44842864071,6.772851525904956,-0.00021941223084723856 +2026-04-24 02:00:00,96349.72654182372,6.824803384476728,-0.0005572610491911323 +2026-04-24 03:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 04:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 05:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 06:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 07:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 08:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 09:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 10:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 11:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 12:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 13:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 14:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 15:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 16:00:00,96349.72654182372,6.824803384476728,0.0 +2026-04-24 17:00:00,96253.47306875496,6.917885498977758,-0.0009990009990010732 +2026-04-24 18:00:00,96270.98695597595,6.900948659155905,0.00018195589896775224 +2026-04-24 19:00:00,96264.26651087952,6.907447679087553,-6.980758491141024e-05 +2026-04-24 20:00:00,96194.21096199549,6.975195038375019,-0.0007277419900780657 +2026-04-24 21:00:00,96179.14087299137,6.989768598221738,-0.00015666315938776018 +2026-04-24 22:00:00,96046.56481972535,7.117976536873327,-0.001378428337606934 +2026-04-24 23:00:00,95863.27995345896,7.2952225350091675,-0.0019082917396411395 +2026-04-27 01:00:00,95401.80939017047,7.741488570315624,-0.004813840748120951 +2026-04-27 02:00:00,95309.14870778023,7.831096269373197,-0.0009712675575290227 +2026-04-27 03:00:00,95672.8673423933,7.4793614330725235,0.003816198544887283 +2026-04-27 04:00:00,95769.60102181167,7.385814934056394,0.0010110879093042653 +2026-04-27 05:00:00,96086.48019060114,7.079376297279297,0.00330876567729775 +2026-04-27 06:00:00,96209.0774011482,6.960818418526229,0.0012759048963378449 +2026-04-27 07:00:00,96226.38763851782,6.944078518702276,0.00017992311990936864 +2026-04-27 08:00:00,95825.19539474731,7.332052284021823,-0.004169253918973017 +2026-04-27 09:00:00,95825.19539474731,7.332052284021823,0.0 +2026-04-27 10:00:00,95825.19539474731,7.332052284021823,0.0 +2026-04-27 11:00:00,95825.19539474731,7.332052284021823,0.0 +2026-04-27 12:00:00,95825.19539474731,7.332052284021823,0.0 +2026-04-27 13:00:00,95729.46592881849,7.424627656365454,-0.0009990009990009767 +2026-04-27 14:00:00,95648.3509781177,7.503070032397775,-0.0008473352474473074 +2026-04-27 15:00:00,95534.70872889025,7.612967997816504,-0.0011881255459745685 +2026-04-27 16:00:00,95472.70356607386,7.672930215084314,-0.0006490328346774152 +2026-04-27 17:00:00,95010.64922459067,8.11976079426675,-0.004839648655842427 +2026-04-27 18:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-27 19:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-27 20:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-27 21:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-27 22:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-27 23:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 01:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 02:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 03:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 04:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 05:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 06:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 07:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 08:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 09:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 10:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 11:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 12:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 13:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 14:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 15:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 16:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 17:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 18:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 19:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 20:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 21:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 22:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-28 23:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-29 01:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-29 02:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-29 03:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-29 04:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-29 05:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-29 06:00:00,95010.64922459067,8.11976079426675,0.0 +2026-04-29 07:00:00,94915.73349109957,8.211549245021729,-0.000999000999001 +2026-04-29 08:00:00,94629.63909105951,8.488217409334181,-0.0030141936380535983 +2026-04-29 09:00:00,94497.72236481626,8.61578774446383,-0.001394031801350376 +2026-04-29 10:00:00,94152.26543796674,8.949862559584625,-0.003655716965493202 +2026-04-29 11:00:00,94169.30660555874,8.933382864229666,0.00018099583172782836 +2026-04-29 12:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 13:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 14:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 15:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 16:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 17:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 18:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 19:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 20:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 21:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 22:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-29 23:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-30 01:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-30 02:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-30 03:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-30 04:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-30 05:00:00,94169.30660555874,8.933382864229666,0.0 +2026-04-30 06:00:00,94075.23137418456,9.024358505723939,-0.0009990009990009847 +2026-04-30 07:00:00,93788.21582415258,9.301917469986634,-0.0030509151648043915 +2026-04-30 08:00:00,94211.92511096237,8.892168552959987,0.004517724141423345 +2026-04-30 09:00:00,94768.81787242729,8.353624289207133,0.005911064451862213 +2026-04-30 10:00:00,94954.03582090115,8.174508900039065,0.0019544186857241323 +2026-04-30 11:00:00,95323.43928781281,7.81727653524341,0.0038903398230320796 +2026-04-30 12:00:00,95504.94048815676,7.6417554348211905,0.0019040563548691947 +2026-04-30 13:00:00,95805.9970866795,7.350618046407526,0.0031522620398897272 +2026-04-30 14:00:00,95608.38997777262,7.541714398061745,-0.002062575568501243 +2026-04-30 15:00:00,95802.48682455682,7.354012652445261,0.002030123578373482 +2026-04-30 16:00:00,95213.1757599588,7.923907101370227,-0.0061513128117146425 +2026-04-30 17:00:00,95391.78615620172,7.7511815588614335,0.001875899998265073 +2026-04-30 18:00:00,95301.55177104777,7.838442902302304,-0.0009459345378667246 +2026-04-30 19:00:00,95142.55754549049,7.992198587541346,-0.001668327772240789 +2026-04-30 20:00:00,95332.11170011593,7.808889861503106,0.0019923172081516465 +2026-04-30 21:00:00,95448.9827802009,7.6958694487170005,0.0012259361300272517 +2026-04-30 22:00:00,95336.65439227472,7.804496841924835,-0.001176842169023859 +2026-04-30 23:00:00,95432.87687163796,7.711444699949002,0.0010092915466418033 +2026-05-01 01:00:00,95612.10672590251,7.538120109315911,0.0018780724226267614 +2026-05-01 02:00:00,95531.99015510223,7.615597000059729,-0.0008379333281501297 +2026-05-01 03:00:00,95392.40561422337,7.7505825107371225,-0.0014611287868308278 +2026-05-01 04:00:00,95416.77096307499,7.72701995118103,0.0002554223126540049 +2026-05-01 05:00:00,95378.98402375424,7.76356188676381,-0.0003960198918843539 +2026-05-01 06:00:00,95535.70690323213,7.612002711313895,0.0016431594557440557 +2026-05-01 07:00:00,95080.0071522962,8.052688070941457,-0.004769941686803159 +2026-05-01 08:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 09:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 10:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 11:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 12:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 13:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 14:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 15:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 16:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 17:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 18:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 19:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 20:00:00,95080.0071522962,8.052688070941457,0.0 +2026-05-01 21:00:00,94985.02213016603,8.144543527414037,-0.0009990009990009394 +2026-05-01 22:00:00,94709.78062409068,8.410716378814831,-0.002897735873537712 +2026-05-01 23:00:00,94804.88272656298,8.318747699562172,0.0010041423583248262 +2026-05-04 01:00:00,94874.10391129984,8.251807213799438,0.0007301436671411553 +2026-05-04 02:00:00,94773.04508966621,8.349536350283904,-0.0010651886812877323 +2026-05-04 03:00:00,94754.96952807321,8.367016358435585,-0.00019072471055345 +2026-05-04 04:00:00,94755.17493218221,8.366817721979325,2.1677396977109164e-06 +2026-05-04 05:00:00,94606.46235725791,8.510630516318264,-0.0015694401390819513 +2026-05-04 06:00:00,94796.0503498755,8.32728906718174,0.0020039645061629275 +2026-05-04 07:00:00,94607.900186021,8.509240061124377,-0.0019847890619922705 +2026-05-04 08:00:00,94466.99296723914,8.645504670125073,-0.0014893811035314884 +2026-05-04 09:00:00,94156.45173800965,8.945814189717995,-0.003287298764100472 +2026-05-04 10:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 11:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 12:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 13:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 14:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 15:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 16:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 17:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 18:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 19:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 20:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 21:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 22:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-04 23:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 01:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 02:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 03:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 04:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 05:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 06:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 07:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 08:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 09:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 10:00:00,94156.45173800965,8.945814189717995,0.0 +2026-05-05 11:00:00,94062.38934866099,9.036777412305689,-0.000999000999001017 +2026-05-05 12:00:00,94019.67489334852,9.078084512127468,-0.00045410770030662886 +2026-05-05 13:00:00,93760.91196116584,9.32832172554047,-0.002752221090704802 +2026-05-05 14:00:00,94148.85000941421,8.953165456627826,0.004137524263938961 +2026-05-05 15:00:00,94191.77081475231,8.91165880559921,0.0004558824174040256 +2026-05-05 16:00:00,94496.9625027094,8.616522570640807,0.003240109887702563 +2026-05-05 17:00:00,94558.24846033161,8.557255862200888,0.0006485494983021668 +2026-05-05 18:00:00,94274.31082501792,8.831838322851835,-0.0030027801903797515 +2026-05-05 19:00:00,94122.43720612921,8.978708011106988,-0.0016109756471262276 +2026-05-05 20:00:00,94095.61170279288,9.004649667999885,-0.0002850064674544682 +2026-05-05 21:00:00,94117.8975055646,8.983098137658093,0.00023684210526317463 +2026-05-05 22:00:00,93987.0715892936,9.109613602812686,-0.001390021661536447 +2026-05-05 23:00:00,94039.07179576094,9.059326698681831,0.0005532697804925525 +2026-05-06 01:00:00,94366.75563651542,8.74243938217477,0.003484549926929864 +2026-05-06 02:00:00,94798.43989020455,8.324978257406027,0.004574537407558105 +2026-05-06 03:00:00,95309.15620372299,7.831089020406694,0.005387391544733746 +2026-05-06 04:00:00,95459.17267238072,7.68601529301336,0.0015739984974483875 +2026-05-06 05:00:00,95763.53896023515,7.39167726288244,0.0031884446442776463 +2026-05-06 06:00:00,95827.507468191,7.32981638875322,0.00066798395976591 +2026-05-06 07:00:00,95933.36503135665,7.22744661962972,0.001104667813683671 +2026-05-06 08:00:00,96222.46141731193,6.947875378807067,0.0030135124089600424 +2026-05-06 09:00:00,96350.19208319797,6.824353181755509,0.001327451657384559 +2026-05-06 10:00:00,96604.6216648417,6.578306543686745,0.00264067539610132 +2026-05-06 11:00:00,96989.05176265378,6.206542645290871,0.0039794172492679325 +2026-05-06 12:00:00,97023.71856696531,6.173018042536996,0.00035743007774083373 +2026-05-06 13:00:00,97358.2119585667,5.849545536203277,0.003447542482826172 +2026-05-06 14:00:00,96823.97174212255,6.366183610785618,-0.005487366763386167 +2026-05-06 15:00:00,96641.14561938422,6.542985980071052,-0.0018882320095819465 +2026-05-06 16:00:00,97028.4646175556,6.168428364779009,0.0040078063612450825 +2026-05-06 17:00:00,97047.861519968,6.1496705513333865,0.00019990940275993436 +2026-05-06 18:00:00,96736.479331241,6.450793322497832,-0.003208542505214648 +2026-05-06 19:00:00,96638.05036899928,6.545979248174058,-0.0010174958084291134 +2026-05-06 20:00:00,96657.03457136036,6.5276205371421705,0.0001964464544617259 +2026-05-06 21:00:00,96698.51092651884,6.487510744561609,0.0004291085004046464 +2026-05-06 22:00:00,96829.74954284115,6.360596176993297,0.001357193767151605 +2026-05-06 23:00:00,96795.49543858093,6.393721677333451,-0.00035375599360678873 +2026-05-07 01:00:00,96879.68624905184,6.31230478493114,0.000869780252577308 +2026-05-07 02:00:00,96964.7024596254,6.230089687701361,0.0008775442393053516 +2026-05-07 03:00:00,96749.89208290917,6.4378224940513835,-0.002215346113248612 +2026-05-07 04:00:00,97289.29104999492,5.916195639297322,0.0055751893410229455 +2026-05-07 05:00:00,97136.7983810292,6.063663981173089,-0.0015674147413341948 +2026-05-07 06:00:00,96968.41676008738,6.226497765977711,-0.0017334483300688451 +2026-05-07 07:00:00,96911.25780297843,6.281773450280272,-0.0005894595273259059 +2026-05-07 08:00:00,97006.5915148352,6.189580792707066,0.0009837217472771515 +2026-05-07 09:00:00,97727.06003768483,5.49285026028911,0.007427005851859484 +2026-05-07 10:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 11:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 12:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 13:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 14:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 15:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 16:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 17:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 18:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 19:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 20:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 21:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 22:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-07 23:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 01:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 02:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 03:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 04:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 05:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 06:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 07:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 08:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 09:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 10:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 11:00:00,97727.06003768483,5.49285026028911,0.0 +2026-05-08 12:00:00,97629.43060707775,5.587262997291818,-0.0009990009990009981 +2026-05-08 13:00:00,97792.90742086436,5.4291724161002435,0.0016744624317695598 +2026-05-08 14:00:00,97749.60367678147,5.471049388811322,-0.00044281068254294607 +2026-05-08 15:00:00,97888.83868167952,5.3364018976063665,0.001424404802278819 +2026-05-08 16:00:00,98376.16119882283,4.865135678388935,0.004978325657003691 +2026-05-08 17:00:00,97712.93017102705,5.506514576226926,-0.006741786015164306 +2026-05-08 18:00:00,97527.90508267292,5.685443459628761,-0.0018935578743803983 +2026-05-08 19:00:00,97639.99754941378,5.577044214477147,0.001149337378320957 +2026-05-08 20:00:00,97814.87008532743,5.4079333773089955,0.0017909928339066075 +2026-05-08 21:00:00,97836.41835989499,5.387095075098706,0.00022029651063030739 +2026-05-08 22:00:00,97878.47893429128,5.346420312130537,0.0004299071358231216 +2026-05-08 23:00:00,97705.26395795975,5.513928202974821,-0.0017696941985359575 +2026-05-11 01:00:00,97212.13998227904,5.990804734325778,-0.005047056378588609 +2026-05-11 02:00:00,97111.61603825168,6.088016616346545,-0.001034067803112685 +2026-05-11 03:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 04:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 05:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 06:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 07:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 08:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 09:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 10:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 11:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 12:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 13:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 14:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 15:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 16:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 17:00:00,97111.61603825168,6.088016616346545,0.0 +2026-05-11 18:00:00,97014.60143681486,6.181834781564987,-0.0009990009990010723 +2026-05-11 19:00:00,96693.35424636914,6.492497522459963,-0.003311328250469044 +2026-05-11 20:00:00,96840.13981647708,6.35054824199997,0.0015180523134396351 +2026-05-11 21:00:00,96866.38081225057,6.325171834319966,0.00027097230366687037 +2026-05-11 22:00:00,97043.30252594213,6.154079335664974,0.0018264511609499398 +2026-05-11 23:00:00,97063.80330389016,6.134254017164975,0.00021125391876019656 +2026-05-12 01:00:00,97398.5810077816,5.8105065660599875,0.003449047868475788 +2026-05-12 02:00:00,97737.45886726263,5.482794051255015,0.0034792894924614336 +2026-05-12 03:00:00,97364.75472416733,5.843218341584993,-0.003813319349764003 +2026-05-12 04:00:00,96912.9175781926,6.280168361324981,-0.004640664347738311 +2026-05-12 05:00:00,96969.90974088816,6.225053975894963,0.0005880760183448074 +2026-05-12 06:00:00,96768.38709365895,6.419936856749968,-0.0020781977395637275 +2026-05-12 07:00:00,96644.35738707334,6.539880033674956,-0.001281717204458204 +2026-05-12 08:00:00,96647.63751154502,6.536707982714959,3.394015502162852e-05 +2026-05-12 09:00:00,96293.58907638243,6.87909123320995,-0.0036632911499807743 +2026-05-12 10:00:00,96351.60627797537,6.822985581854953,0.0006025032626722568 +2026-05-12 11:00:00,96326.3212334776,6.847437534933294,-0.00026242473244108704 +2026-05-12 12:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 13:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 14:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 15:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 16:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 17:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 18:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 19:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 20:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 21:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 22:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-12 23:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-13 01:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-13 02:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-13 03:00:00,96326.3212334776,6.847437534933294,0.0 +2026-05-13 04:00:00,96230.09114233527,6.9404970378954,-0.000999000999001019 +2026-05-13 05:00:00,96014.81862100294,7.148676762124843,-0.0022370603495939305 +2026-05-13 06:00:00,96096.87592238533,7.069323083972737,0.0008546316345843611 +2026-05-13 07:00:00,96169.52016675127,6.999072321518886,0.000755948033363954 +2026-05-13 08:00:00,96259.96736677873,6.911605175027054,0.0009404975700266088 +2026-05-13 09:00:00,96214.12987174217,6.955932416688087,-0.0004761844024099849 +2026-05-13 10:00:00,96236.84398758618,6.933966685329272,0.00023607879501987133 +2026-05-13 11:00:00,96191.82501924671,6.9775023691035,-0.00046779348193585953 +2026-05-13 12:00:00,96196.53154775494,6.972950911254359,4.892857066901098e-05 +2026-05-13 13:00:00,96058.81443097103,7.106130525709144,-0.001431622477111292 +2026-05-13 14:00:00,96028.58112425599,7.135367705705592,-0.00031473745427883286 +2026-05-13 15:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-13 16:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-13 17:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-13 18:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-13 19:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-13 20:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-13 21:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-13 22:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-13 23:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-14 01:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-14 02:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-14 03:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-14 04:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-14 05:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-14 06:00:00,96028.58112425599,7.135367705705592,0.0 +2026-05-14 07:00:00,95932.64847578021,7.22813956613945,-0.0009990009990009621 +2026-05-14 08:00:00,95670.3021992151,7.481842059936287,-0.0027346923152167463 +2026-05-14 09:00:00,95886.13607215903,7.273119479503112,0.002256017468142775 +2026-05-14 10:00:00,95741.70281880903,7.412793947316615,-0.0015062996515085886 +2026-05-14 11:00:00,95745.78285421438,7.408848340881222,4.2615028615809935e-05 +2026-05-14 12:00:00,95873.07995886184,7.28574542009644,0.0013295322347646635 +2026-05-14 13:00:00,95822.69152160555,7.334473659573754,-0.0005255744081436721 +2026-05-14 14:00:00,95761.69499229532,7.393460475783119,-0.000636556209616336 +2026-05-14 15:00:00,95983.0369130365,7.179411326662135,0.0023113826541916096 +2026-05-14 16:00:00,95464.66841478458,7.680700624280953,-0.005400626141070909 +2026-05-14 17:00:00,95463.85240770351,7.681489745568032,-8.547739122965505e-06 +2026-05-14 18:00:00,95591.76151766177,7.55779498381794,0.0013398695603859439 +2026-05-14 19:00:00,95348.416373983,7.793122393884436,-0.002545670670937596 +2026-05-14 20:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-14 21:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-14 22:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-14 23:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 01:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 02:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 03:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 04:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 05:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 06:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 07:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 08:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 09:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 10:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 11:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 12:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 13:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 14:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 15:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 16:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 17:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 18:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 19:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 20:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 21:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 22:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-15 23:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 01:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 02:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 03:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 04:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 05:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 06:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 07:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 08:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 09:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 10:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 11:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 12:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 13:00:00,95348.416373983,7.793122393884436,0.0 +2026-05-18 14:00:00,95253.16321077223,7.885237156727706,-0.000999000999000988 +2026-05-18 15:00:00,95688.1196750426,7.464611634142616,0.004566320420330018 +2026-05-18 16:00:00,95777.49715831665,7.378178959386192,0.000934049948703893 +2026-05-18 17:00:00,95167.76271153147,7.967823881085116,-0.006366155567599678 +2026-05-18 18:00:00,95103.71233710088,8.029763924353194,-0.0006730259554880631 +2026-05-18 19:00:00,95243.11609321448,7.89495324194623,0.0014658077238823384 +2026-05-18 20:00:00,95176.55393939448,7.959322306518921,-0.0006988657716203051 +2026-05-18 21:00:00,94941.49325153325,8.186638216943878,-0.0024697331236736113 +2026-05-18 22:00:00,95417.68476078018,7.726136261274441,0.005015631131746897 +2026-05-18 23:00:00,95575.71754736542,7.573310337524804,0.0016562211395240408 +2026-05-19 01:00:00,95740.86704222072,7.413602186745388,0.001727944075057099 +2026-05-19 02:00:00,95904.34201748307,7.25551338350239,0.0017074733111645606 +2026-05-19 03:00:00,95517.94662140842,7.6291778275313,-0.004028966655171934 +2026-05-19 04:00:00,95530.08688845736,7.617437557892254,0.00012709933031801915 +2026-05-19 05:00:00,95178.43777393656,7.957500540540444,-0.0036810299872477885 +2026-05-19 06:00:00,95135.52820936705,7.998996321161209,-0.0004508328311862187 +2026-05-19 07:00:00,95081.73426744335,8.05101786076869,-0.0005654453487168597 +2026-05-19 08:00:00,95047.19730083861,8.084416903707362,-0.00036323450419603154 +2026-05-19 09:00:00,95199.15995389939,7.937461114777249,0.0015988125623504063 +2026-05-19 10:00:00,95390.47381739464,7.752450658741264,0.0020096171393517858 +2026-05-19 11:00:00,94992.925702201,8.136900362199622,-0.004167587173900236 +2026-05-19 12:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 13:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 14:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 15:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 16:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 17:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 18:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 19:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 20:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 21:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 22:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-19 23:00:00,94992.925702201,8.136900362199622,0.0 +2026-05-20 01:00:00,94992.925702201,8.136900362199622,0.0 diff --git a/backtest_output/sma_cross_metrics.csv b/backtest_output/sma_cross_metrics.csv new file mode 100644 index 0000000..4aa0d48 --- /dev/null +++ b/backtest_output/sma_cross_metrics.csv @@ -0,0 +1,43 @@ +metric,value +Start Value,100000.0 +End Value,94992.925702201 +Total Return [%],-5.007074297799001 +Total Fees Paid,4299.095017658301 +Max Drawdown [%],9.32832172554047 +Max Drawdown Duration,648.0 +Total Trades,22.0 +Total Closed Trades,22.0 +Total Open Trades,0.0 +Open Trade PnL,-0.0 +Win Rate [%],22.727272727272727 +Best Trade [%],3.796000000000013 +Worst Trade [%],-2.198000000000002 +Avg Winning Trade [%],2.0027355950916155 +Avg Losing Trade [%],-0.8772319382499455 +Avg Winning Trade Duration,27.6 +Avg Losing Trade Duration,10.588235294117647 +Profit Factor,0.6591787877351036 +Expectancy,-227.5942862635913 +SQN,-0.7238525883160277 +Sharpe Ratio,-0.6039451115606126 +Sortino Ratio,-0.8636398465951732 +Calmar Ratio,-0.24337622652512184 +Omega Ratio,0.8626407142511092 +total_trades,22.0 +total_closed_trades,22.0 +total_open_trades,0.0 +winning_trades,5.0 +losing_trades,17.0 +max_consecutive_wins,2.0 +max_consecutive_losses,8.0 +avg_holding_period,14.454545454545455 +avg_winning_duration,27.6 +avg_losing_duration,10.588235294117647 +start_value,100000.0 +end_value,94992.925702201 +total_fees_paid,4299.095017658301 +open_trade_pnl,-0.0 +exposure_pct,38.922888616891065 +payoff_ratio,2.2830171905129446 +recovery_factor,-0.5367604640060695 +omega_ratio,0.8626407142511092 diff --git a/backtest_output/sma_cross_trades.csv b/backtest_output/sma_cross_trades.csv new file mode 100644 index 0000000..e5da332 --- /dev/null +++ b/backtest_output/sma_cross_trades.csv @@ -0,0 +1,23 @@ +trade_id,symbol,direction,entry_idx,exit_idx,entry_time,exit_time,entry_price,exit_price,size,pnl,return_pct,fees,exit_reason +0,XAUUSD,Long,79,88,2026-04-02 23:00:00,2026-04-06 09:00:00,4676.04,4666.08,21.36425263686793,-412.375368107142,-0.41278774347524905,199.58741184393665,Signal +1,XAUUSD,Long,91,103,2026-04-06 12:00:00,2026-04-07 01:00:00,4693.64,4658.49,21.196371365378994,-943.2836740303851,-0.9481368404905478,198.23122053730185,Signal +2,XAUUSD,Long,116,128,2026-04-07 14:00:00,2026-04-08 03:00:00,4659.81,4846.202400000001,21.148028602603922,3740.798384376097,3.796000000000013,201.0334221319076,TakeProfit +3,XAUUSD,Long,163,179,2026-04-09 15:00:00,2026-04-10 08:00:00,4745.67,4765.93,21.552880096119793,231.6589764251387,0.22648856747309057,205.00237432225305,Signal +4,XAUUSD,Long,190,195,2026-04-10 19:00:00,2026-04-13 01:00:00,4763.89,4668.6122000000005,21.519028364347008,-2253.2639630811436,-2.1979999999999964,202.97828238856556,StopLoss +5,XAUUSD,Long,210,252,2026-04-13 16:00:00,2026-04-15 12:00:00,4730.63,4798.21,21.194485953139022,1230.3644951834242,1.2271338067022768,201.95886552970924,Signal +6,XAUUSD,Long,269,280,2026-04-16 06:00:00,2026-04-16 17:00:00,4824.21,4788.8,21.03814022281817,-947.2003976333417,-0.933272183424848,202.2398523433533,Signal +7,XAUUSD,Long,301,311,2026-04-17 15:00:00,2026-04-20 02:00:00,4846.36,4749.4328,20.746736169172703,-2210.0044275722853,-2.198000000000002,199.081381555647,StopLoss +8,XAUUSD,Long,327,341,2026-04-20 18:00:00,2026-04-21 09:00:00,4799.78,4778.83,20.488096468982008,-625.4731067439251,-0.6360418602519244,196.24748571875574,Signal +9,XAUUSD,Long,364,374,2026-04-22 09:00:00,2026-04-22 19:00:00,4766.97,4728.7,20.498032798906856,-979.1022703217702,-1.002013228528823,194.64255510759585,Signal +10,XAUUSD,Long,398,403,2026-04-23 20:00:00,2026-04-24 02:00:00,4709.49,4695.41,20.540522066132304,-482.3921066709091,-0.49867183070778215,193.18155597976772,Signal +11,XAUUSD,Long,418,432,2026-04-24 17:00:00,2026-04-27 08:00:00,4726.42,4710.1,20.36498514071009,-524.5311470763963,-0.5449477617308599,192.1745895800136,Signal +12,XAUUSD,Long,437,441,2026-04-27 13:00:00,2026-04-27 17:00:00,4708.88,4678.2,20.329561579148013,-814.5461701566558,-0.8508834372504777,190.83522090838875,Signal +13,XAUUSD,Long,477,481,2026-04-29 07:00:00,2026-04-29 11:00:00,4604.88,4573.24,20.611988475508497,-841.3426190319296,-0.8864100693177742,189.17930366683407,Signal +14,XAUUSD,Long,499,523,2026-04-30 06:00:00,2026-05-01 07:00:00,4556.01,4609.28,20.648600721724616,910.7005467374452,0.968055601282691,189.25041370881542,Signal +15,XAUUSD,Long,537,548,2026-05-01 21:00:00,2026-05-04 09:00:00,4624.3,4588.55,20.54041090114526,-923.5554142865592,-0.9723168912051553,189.23572457061613,Signal +16,XAUUSD,Long,573,617,2026-05-05 11:00:00,2026-05-07 09:00:00,4558.39,4740.725600000001,20.635002566401955,3570.608299675179,3.796000000000007,191.8872742712685,TakeProfit +17,XAUUSD,Long,643,656,2026-05-08 12:00:00,2026-05-11 02:00:00,4711.96,4691.66,20.719494776500174,-615.4439994331499,-0.6303877791831889,194.83825547019256,Signal +18,XAUUSD,Long,672,688,2026-05-11 18:00:00,2026-05-12 11:00:00,4732.24,4703.37,20.500777948036212,-785.2948047740731,-0.8094604246614688,193.43734541426994,Signal +19,XAUUSD,Long,704,714,2026-05-13 04:00:00,2026-05-13 14:00:00,4702.6,4697.45,20.463167427026594,-297.7401092216195,-0.30940437204951615,192.35479697242135,Signal +20,XAUUSD,Long,730,742,2026-05-14 07:00:00,2026-05-14 19:00:00,4702.54,4678.58,20.40017702683661,-680.1647502730034,-0.7090023689325352,191.37650870999744,Signal +21,XAUUSD,Long,783,803,2026-05-18 14:00:00,2026-05-19 11:00:00,4550.71,4542.82,20.93149491195269,-355.4906717820027,-0.3732061590389264,190.34117692668914,Signal diff --git a/benches/backtest_benchmark.rs b/benches/backtest_benchmark.rs new file mode 100644 index 0000000..7280c1d --- /dev/null +++ b/benches/backtest_benchmark.rs @@ -0,0 +1,123 @@ +//! Benchmark for RaptorBT backtesting performance. + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; +use raptorbt::core::types::{BacktestConfig, CompiledSignals, Direction, OhlcvData}; +use raptorbt::indicators::trend::{ema, sma}; +use raptorbt::portfolio::engine::PortfolioEngine; + +/// Generate sample OHLCV data. +fn generate_sample_data(n: usize) -> OhlcvData { + let mut open = vec![100.0; n]; + let mut high = vec![101.0; n]; + let mut low = vec![99.0; n]; + let mut close = vec![100.0; n]; + + // Create a trending pattern + for i in 1..n { + let change = (i as f64 * 0.1).sin() * 2.0; + close[i] = close[i - 1] + change; + open[i] = close[i - 1]; + high[i] = close[i].max(open[i]) + 1.0; + low[i] = close[i].min(open[i]) - 1.0; + } + + OhlcvData { + timestamps: (0..n as i64).collect(), + open, + high, + low, + close, + volume: vec![1000.0; n], + } +} + +/// Generate sample trading signals based on SMA crossover. +fn generate_sample_signals( + close: &[f64], + fast_period: usize, + slow_period: usize, +) -> CompiledSignals { + let n = close.len(); + let fast_sma = sma(close, fast_period).unwrap_or_else(|_| vec![0.0; n]); + let slow_sma = sma(close, slow_period).unwrap_or_else(|_| vec![0.0; n]); + + let mut entries = vec![false; n]; + let mut exits = vec![false; n]; + + for i in 1..n { + // Entry: fast crosses above slow + if fast_sma[i] > slow_sma[i] && fast_sma[i - 1] <= slow_sma[i - 1] { + entries[i] = true; + } + // Exit: fast crosses below slow + if fast_sma[i] < slow_sma[i] && fast_sma[i - 1] >= slow_sma[i - 1] { + exits[i] = true; + } + } + + CompiledSignals { + symbol: "BENCH".to_string(), + entries, + exits, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + } +} + +fn bench_single_backtest(c: &mut Criterion) { + let mut group = c.benchmark_group("single_backtest"); + + for size in [1000, 5000, 10000, 50000].iter() { + group.bench_with_input(BenchmarkId::new("bars", size), size, |b, &size| { + let ohlcv = generate_sample_data(size); + let signals = generate_sample_signals(&ohlcv.close, 10, 30); + let config = BacktestConfig::default(); + let engine = PortfolioEngine::new(config); + + b.iter(|| { + let result = engine.run_single(black_box(&ohlcv), black_box(&signals)); + black_box(result) + }); + }); + } + + group.finish(); +} + +fn bench_sma(c: &mut Criterion) { + let mut group = c.benchmark_group("sma"); + + for size in [1000, 5000, 10000, 50000].iter() { + group.bench_with_input(BenchmarkId::new("data_size", size), size, |b, &size| { + let ohlcv = generate_sample_data(size); + + b.iter(|| { + let result = sma(black_box(&ohlcv.close), black_box(20)); + black_box(result) + }); + }); + } + + group.finish(); +} + +fn bench_ema(c: &mut Criterion) { + let mut group = c.benchmark_group("ema"); + + for size in [1000, 5000, 10000, 50000].iter() { + group.bench_with_input(BenchmarkId::new("data_size", size), size, |b, &size| { + let ohlcv = generate_sample_data(size); + + b.iter(|| { + let result = ema(black_box(&ohlcv.close), black_box(20)); + black_box(result) + }); + }); + } + + group.finish(); +} + +criterion_group!(benches, bench_single_backtest, bench_sma, bench_ema); +criterion_main!(benches); diff --git a/ferro-ta-main/.cargo/config.toml b/ferro-ta-main/.cargo/config.toml new file mode 100644 index 0000000..b73ec86 --- /dev/null +++ b/ferro-ta-main/.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/ferro-ta-main/.devcontainer/devcontainer.json b/ferro-ta-main/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f7c676d --- /dev/null +++ b/ferro-ta-main/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +{ + "name": "ferro-ta dev", + "image": "mcr.microsoft.com/devcontainers/rust:1-bookworm", + "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "lts" + } + }, + "postCreateCommand": "pip install uv && uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml sphinx sphinx-rtd-theme ruff mypy pyright && rustup component add rustfmt clippy", + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "ms-python.python", + "ms-python.mypy-type-checker", + "tamasfe.even-better-toml", + "charliermarsh.ruff" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "editor.formatOnSave": true, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } + } + } + }, + "remoteUser": "vscode" +} diff --git a/ferro-ta-main/.gitignore b/ferro-ta-main/.gitignore new file mode 100644 index 0000000..77ff27d --- /dev/null +++ b/ferro-ta-main/.gitignore @@ -0,0 +1,55 @@ +# Rust build artifacts +/target/ +wasm/target/ + +# Compiled Python extension +*.so +*.pyd +*.dll + +# macOS dSYM debug symbols (generated by maturin develop) +*.dSYM/ + +# +/plans/ + +# Maturin / wheel build outputs +dist/ +*.egg-info/ +__pycache__/ +*.pyc +*.pyo + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# Issue tracker +/myissues/ + +# WASM build output +wasm/pkg/ +wasm/pkg-web/ +wasm/node/ +wasm/web/ +benchmark_vs_talib.json +wasm_benchmark.json +.wasm_benchmark.prepush.json +.coverage +.coverage.* +coverage.xml +.hypothesis/ + +/docs/_build/ + + +# DS Store in all directories +.DS_Store +*.DS_Store diff --git a/ferro-ta-main/.pre-commit-config.yaml b/ferro-ta-main/.pre-commit-config.yaml new file mode 100644 index 0000000..e849b64 --- /dev/null +++ b/ferro-ta-main/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +# Pre-commit hooks for ferro-ta +# Install: pre-commit install --hook-type pre-commit --hook-type pre-push +# Run: pre-commit run --all-files +default_language_version: + python: python3 + +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.7 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + exclude: ^conda/meta\.yaml$ + - id: check-added-large-files + args: [--maxkb=1000] + - id: check-merge-conflict + - id: debug-statements + + # Optional: uncomment to run mypy on commit (after type errors are fixed) + # - repo: local + # hooks: + # - id: mypy + # name: mypy + # entry: mypy python/ferro_ta --ignore-missing-imports + # language: system + # pass_filenames: false + + - repo: local + hooks: + - id: ci-basic-pre-push + name: ci basic pre-push + entry: scripts/pre_push_checks.sh + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/ferro-ta-main/CHANGELOG.md b/ferro-ta-main/CHANGELOG.md new file mode 100644 index 0000000..5397a72 --- /dev/null +++ b/ferro-ta-main/CHANGELOG.md @@ -0,0 +1,553 @@ +# Changelog + +All notable changes to **ferro-ta** are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and the project uses [Semantic Versioning](https://semver.org/). + +--- + +## [Unreleased] + +## [1.2.0] — 2026-06-29 + +### Added + +- **Runtime CPU-feature dispatch** for SIMD hot paths via the `multiversion` + crate (`ferro_ta_core/src/simd.rs`). One binary now selects baseline / + AVX2-FMA / AVX-512 / NEON kernels at load time via CPUID instead of being + pinned at build time, so it runs on any CPU of the target architecture with + no illegal-instruction crashes on older chips. The `simd` feature is on by + default; `--no-default-features` gives a pure-scalar build. +- **Broader wheel coverage**: release builds now also produce Linux `aarch64` + (manylinux) and `musllinux` (x86_64 + aarch64) wheels and a Windows `arm64` + wheel, alongside the existing Linux x86_64, macOS universal2, and Windows + x64 wheels. +- **abi3 wheels** (`cp310-abi3`): a single stable-ABI wheel per platform now + covers CPython 3.10+ (including future 3.14+), replacing the per-version + wheel matrix. +- **Dynamic Time Warping** (`DTW`, `DTW_DISTANCE`, `BATCH_DTW`): Euclidean-cost + DTW with optional Sakoe-Chiba band (`window=` parameter). `DTW()` returns + `(distance, path)` with the optimal warping path as an `(N, 2)` index array; + `DTW_DISTANCE()` is the faster distance-only variant; `BATCH_DTW()` computes + distances from each row of a 2-D matrix to a reference series in parallel + via rayon. Distance convention matches `dtaidistance.dtw.distance()`. +- **Indicator-specific exception types** (`FerroTaError`, `InvalidPeriodError`, + `InsufficientDataError`, `LengthMismatchError`, `NumericConvergenceError`, + `InvalidInputError`): finer-grained errors for catching specific failure + modes. All subclass `ValueError` so existing `except ValueError` code keeps + working (backward compatible). + +### Changed + +- **SIMD is now enabled by default and runtime-dispatched.** Replaced the + compile-time `wide` crate (which was never actually enabled in published + wheels) with `multiversion`. The `wide` Cargo feature is removed; use the + default `simd` feature instead. +- **Dependency bumps.** Rust: `log` 0.4.32, `serde_json` 1.0.150, + `rayon` 1.12.0. API service (`api/requirements.txt`): `uvicorn>=0.49.0`, + `pydantic>=2.13.4`, `ferro-ta>=1.1.4`. CI actions: `actions/deploy-pages` v5, + `actions/upload-pages-artifact` v5, `softprops/action-gh-release` v3. +- Python coverage threshold raised from 65% to 80% and enforced in CI. + +### Fixed + +- **aarch64 Linux containers can now install ferro-ta.** Previously no Linux + `aarch64` wheel was published, so arm64 images (e.g. AWS Graviton) fell back + to an sdist build that failed without a Rust toolchain. A manylinux/musllinux + aarch64 wheel is now published. +- Published wheels now actually ship SIMD-accelerated kernels; the prior build + enabled no SIMD feature at all. + +### Security + +- **SLSA build provenance** attestations are now generated for every PyPI + wheel and sdist via `actions/attest-build-provenance`. Verify with + `gh attestation verify `. +- **Sigstore keyless signatures** are now published alongside both + CycloneDX/SPDX SBOMs on every GitHub Release (`.sig` + `.pem` files). +- `ferro_ta_core` crate now declares `#![forbid(unsafe_code)]` to prevent + regression — the pure-logic layer has no unsafe code and never will. +- Dependabot now covers the WASM npm package in addition to pip, cargo, + and GitHub Actions. +- **pip-audit fixes**: bumped dev lockfile deps `idna` 3.18, `pytest` 9.1.1, + and `urllib3` 2.7.0 to clear PYSEC-2026-215, CVE-2025-71176, and + PYSEC-2026-141/142. +- **pyo3 advisories triaged**: RUSTSEC-2026-0176 and RUSTSEC-2026-0177 are + ignored in `deny.toml` with rationale — ferro-ta uses neither affected code + path (PyList/PyTuple `nth` iterators; `PyCFunction::new_closure`). The + upstream fix requires pyo3 >=0.29 (a large API migration), tracked for a + follow-up. + +## [1.1.3] — 2026-04-02 + +### Added + +- **Stock instrument** (`instrument="stock"`) in `PayoffLeg` and `StrategyLeg` + for modelling equity-holding strategies (Covered Call, Protective Put, Collar, + Covered Strangle, Stock + Spread). Linear payoff identical to futures. + Exposed in all three layers: Rust core, Python, and WASM. +- **Extended Greeks** (`extended_greeks`): closed-form vanna (∂Δ/∂σ), volga + (∂²V/∂σ²), charm (∂Δ/∂t), speed (∂Γ/∂S), and color (∂Γ/∂t) for BSM. + Batch vectorisation supported. +- **Digital options** (`digital_option_price`, `digital_option_greeks`): + cash-or-nothing and asset-or-nothing pricing (BSM closed-form) plus + numerical delta / gamma / vega. Scalar and batch variants. +- **American options** (`american_option_price`, `early_exercise_premium`): + Barone-Adesi-Whaley (1987) quadratic approximation — O(1) per evaluation. + Scalar and batch variants. +- **Historical volatility estimators** (all rolling, annualised): close-to-close, + Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang. Yang-Zhang is + ~14× more efficient than close-to-close and handles overnight gaps. +- **Volatility cone** (`vol_cone`): min / p25 / median / p75 / max distribution + of realised vol across user-specified window lengths — contextualises current + IV against historical norms. +- **`strategy_value`**: pre-expiry BSM mid-price value of a multi-leg strategy + over a spot grid (time value included), complementing `strategy_payoff` + (expiry intrinsic). +- **`expected_move`**: log-normal ±1σ expected price range over N days. +- **`put_call_parity_deviation`**: detects stale quotes or data errors by + computing C − P − (S·e^{−qT} − K·e^{−rT}). +- All new analytics exposed to **WASM** (`wasm/src/lib.rs`): + `extended_greeks`, `digital_price`, `digital_greeks`, `american_price`, + `early_exercise_premium`, `close_to_close_vol`, `parkinson_vol`, + `garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol`, `vol_cone`, + `expected_move`, `put_call_parity_deviation`, `strategy_payoff_dense`, + `aggregate_greeks_dense`, `strategy_value_grid`. +- `aggregate_greeks_dense` added to `ferro_ta_core::options::payoff` (pure + Rust, no PyO3/numpy dependency) enabling WASM reuse. +- Comprehensive docstrings (NumPy style with Parameters / Returns / Notes / + Examples) on all new Python functions. +- Accuracy test suite `tests/unit/test_derivatives_accuracy.py` validates + digital options, extended Greeks, American options, and vol estimators + against scipy and analytical reference formulas. +- scipy added to `dev` optional dependencies for reference testing. + +### Changed + +- `StrategyLeg.expiry_selector`, `StrategyLeg.strike_selector`, and + `StrategyLeg.option_type` are now `Optional` (None allowed for stock legs). + Existing option legs are unaffected. +- `docs/derivatives-analytics.md` rewritten to cover all new features with + runnable examples and an efficiency comparison table for vol estimators. + +## [1.1.2] — 2026-04-01 + +### Changed + +- WASM npm package now ships both Node.js (CommonJS) and browser/web worker + (ESM) builds via conditional exports in package.json. +- Fixed wasm-publish.yml: added job condition, workflow_dispatch inputs, and + pre-publish test gate. +- Fixed CI.yml: SBOM job now waits for both PyPI and crates.io publish. +- Aligned Node.js version to 20 across all CI workflows. +- Rewrote wasm/README.md and ferro_ta_core/README.md to reflect full feature + parity (200+ WASM exports, 22 core modules). + +## [1.1.1] — 2026-04-01 + +### Added + +- Full feature parity across Rust core, Python, and WASM targets. +- 56 new pure-Rust indicator functions in ferro_ta_core: ROC/ROCP/ROCR/ROCR100, + WILLR, AROON/AROONOSC, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC, + DEMA, TEMA, TRIMA, KAMA, T3, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE, + MACDFIX, MACDEXT, MA (generic dispatcher), MAVP, VAR, LINEARREG variants, + TSF, BETA, CORREL, NATR, and 19 math operators/transforms. +- 120+ new WASM bindings: all 61 candlestick patterns (via macro), 9 streaming + API structs, options pricing/greeks/IV/chain/surface, futures basis/roll/curve/ + synthetic, backtest engine (close-only + OHLCV), walk-forward analysis, + Monte Carlo bootstrap, performance metrics, batch operations, portfolio + analytics, and signal utilities. +- `workflow_dispatch` trigger added to `wasm-publish.yml` for manual npm + publishing. + +## [1.0.6] — 2026-03-24 + +### Added + +- Added a repo-managed pre-push gate that mirrors the core local CI and + release checks, including version and changelog validation, Rust formatting + and clippy, Python linting and type checks, tests, docs, and the WASM smoke + suite. +- Added generated API manifest tooling and CI coverage so Python and WASM + export drift is detected before release candidates are pushed. +- Expanded the Rust-backed implementation surface for analysis and data-heavy + workflows, including backtest signal generation, portfolio loops, payoff and + Greeks aggregation, chunked indicator execution, and related helper paths. +- Expanded the WASM package surface with additional indicator exports such as + `WMA`, `ADX`, and `MFI`, along with refreshed Node examples and conformance + coverage against the Python package. + +### Changed + +- Refreshed benchmark wrappers, perf-contract artifacts, and benchmark + comparison helpers so the checked-in performance evidence stays aligned with + the current feature set. +- Hardened Python CI and local tooling so they run the same typecheck and test + entrypoints, including installing the optional MCP dependency needed by the + MCP server tests. +- Updated local pre-commit integration to match the current Ruff + configuration and refreshed locked dependencies to pick up the audited + PyJWT security fix. + +### Fixed + +- One-off benchmark output files produced in the repository root are now + ignored so local benchmarking no longer dirties the repo by default. +- Tightened API typing and MCP helper behavior so the stricter lint and + typecheck pipeline passes consistently before release. + +## [1.0.4] — 2026-03-24 + +### Added + +- The optional MCP server now exposes the broad public ferro-ta callable + surface, including exact top-level exports, non-top-level public analysis and + tooling functions, and generic stored-instance tools for stateful classes and + returned callables. +- Added a dedicated `TA_LIB_COMPATIBILITY.md` document so the full TA-Lib + coverage matrix remains available without bloating the project homepage. + +### Changed + +- Reworked the root README into a shorter product-first landing page with a + compatibility summary and docs map, and refreshed MCP documentation to match + the expanded server behavior. +- Updated the MCP implementation to use generated tool registration over the + public API while keeping the legacy lowercase aliases (`sma`, `ema`, `rsi`, + `macd`, `backtest`) available for existing clients. +- Refreshed locked Python dependency resolutions for the latest low-risk direct + updates in this release cycle. + +### Fixed + +- The repository no longer tracks the stray `.coverage` artifact, and coverage + outputs are now ignored consistently. +- MCP tests now cover generated tool discovery, stored-instance workflows, and + callable-reference execution paths so the broader server surface does not + regress silently. + +## [1.0.3] — 2026-03-24 + +### Added + +- Public package metadata helpers: `ferro_ta.__version__`, `ferro_ta.about()`, + and `ferro_ta.methods()` for quick API discovery across the top-level, + indicators, data, and analysis surfaces. +- A standalone derivatives benchmark runner covering selected + Black-Scholes-Merton pricing, implied-volatility recovery, Greeks, and + Black-76 pricing paths with reproducible machine/runtime metadata, per-run + timing samples, variance stats, and Python-tracked allocation snapshots. +- A one-command version bump helper, `scripts/bump_version.py`, plus `make version + VERSION=X.Y.Z` for aligned Cargo, Python, WASM, Conda, and docs release + surfaces. + +### Changed + +- Tightened the homepage and docs product narrative so the core Rust-backed + Python TA library leads, while adjacent tooling is called out separately. +- Strengthened benchmark evidence and support documentation with clearer + benchmark caveats, support-matrix pages, and more explicit release/version + consistency guidance. + +### Fixed + +- Python CI now recognizes the top-level metadata API in type stubs, and the + derivatives benchmark smoke test no longer depends on importing the + `benchmarks` package from an installed wheel layout. +- The tag-driven GitHub Release workflow now uses a valid glob trigger and an + explicit semantic-version validation step, so pushing `v1.0.3`-style tags + correctly creates the release that fans out into the publish jobs. + +## [1.0.2] — 2026-03-24 + +### Performance + +- Optimized rolling statistical kernels (`CORREL`, `BETA`, `LINEARREG*`, `TSF`) + with incremental window math and matching warmup semantics. +- Vectorized Python analysis hotspots in options, backtesting, features, and + rank-composition paths, reducing Python-loop overhead on common workflows. +- Added grouped multi-indicator execution for shared-input workloads and + refactored batch execution around explicit series-major workspaces. + +### Added + +- Reproducible perf-contract artifacts for single-series, batch, streaming, + SIMD, TA-Lib comparison, and WASM benchmark runs. +- Hotspot and TA-Lib regression gates suitable for CI perf smoke coverage. +- Streaming, SIMD, and WASM benchmark scripts plus updated performance docs and + benchmark playbook. + +## [1.0.1] — 2026-03-24 + +### Added + +- `crates/ferro_ta_core/README.md` is now shipped with the published Rust crate, and + `ferro_ta_core` metadata now points documentation to docs.rs. + +### Fixed + +- CI has been modularized into focused workflow files (`ci-rust.yml`, + `ci-python.yml`, `ci-wasm.yml`, `ci-docs.yml`) while keeping the release + publishing jobs in `CI.yml` for PyPI and crates.io trusted-publisher + compatibility. +- The `ci-complete` gate no longer fails successful runs because of an escaped + shell variable, and the release SBOM job now uses a valid `anchore/sbom-action` + version. +- The npm publish workflow now uses GitHub OIDC trusted publishing, installs + the `wasm32-unknown-unknown` target, and no longer depends on an `NPM_TOKEN` + secret. +- The WASM npm package now removes the generated `pkg/.gitignore` during + `prepack`, so the published tarball includes the built `pkg/` artifacts. + +## [1.0.0] — 2026-03-23 *(initial stable release)* + +### Performance + +- **SMA/EMA** (`src/overlap/sma.rs`, `src/overlap/ema.rs`): Replaced per-bar `ta::SimpleMovingAverage` / `ta::ExponentialMovingAverage` state-machine objects with `ferro_ta_core::overlap::sma` (O(n) sliding-window sum) and `ferro_ta_core::overlap::ema` (O(n) recurrence). SMA/EMA now run at **200–600 M bars/s** on 1 M input. +- **WMA** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/wma.rs`): Replaced O(n × period) double-loop with an **O(n) incremental algorithm** using running weighted sum `T[i] = T[i-1] + n·close[i] - S[i-1]` and sliding sum `S`. ~10× speedup vs previous implementation for large periods. +- **BBANDS** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/bbands.rs`): Replaced O(n × period) per-window variance with **O(n) sliding `sum` and `sum_sq`** accumulators (`var = sum_sq/n - mean²`). ~10× speedup. +- **MACD** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/macd.rs`): Replaced `ta::MovingAverageConvergenceDivergence` per-bar object with a pure-Rust implementation. Fast and slow EMAs now advance **in a single combined loop** to minimise allocation and memory round-trips. +- **MFI** (`src/momentum/mfi.rs`): Removed per-bar `ta::DataItem::builder().build()` allocation. Replaced `ta::MoneyFlowIndex` with `ferro_ta_core::volume::mfi` — a direct O(n) sliding-window implementation on raw high/low/close/volume slices. ~5× speedup. +- **batch_sma / batch_ema** (`src/batch/mod.rs`): Batch functions now delegate to `ferro_ta_core` O(n) implementations instead of constructing per-bar `ta` indicator objects. + +### Fixed +- **Rust clippy**: Removed dead code `compute_ema` function from `src/extended/mod.rs`. +- **fuzz/Cargo.toml**: Added `[workspace]` table to prevent cargo workspace detection error (same fix as `wasm/Cargo.toml`). +- **Python lint**: Replaced deprecated `typing.Dict/List/Tuple/Type` with built-in equivalents across 21 Python files (ruff UP035). +- **Type checking (mypy)**: Fixed `_normalize_rust_error` return type to `NoReturn`; fixed type errors in `_utils.py`, `crypto.py`, `chunked.py`, `regime.py`, `features.py`, `dsl.py`, `mcp/__init__.py`. +- **Type checking (pyright)**: Set `reportMissingImports = false` to handle Rust extension and optional deps; fixed `gpu.py` cupy handling with `Any` type annotation. +- **Sphinx docs**: Fixed RST title underline lengths; fixed unexpected indentation in `plugins.rst`; fixed invalid `:doc:` references in `index.rst` and `contributing.rst`. +- **Sphinx autodoc**: Fixed `conf.py` to not override `sys.path` when the wheel is installed; added `suppress_warnings` for autodoc import failures. +- **CI test coverage**: Added `pandas`, `polars`, `hypothesis`, `pyyaml` to CI test dependencies; coverage threshold adjusted from 80% to 65% (up from failing 59%). +- **Exception hierarchy**: All `FerroTAError` subclasses now accept `code` and `suggestion` keyword arguments; validation helpers (`check_timeperiod`, `check_equal_length`, `check_finite`, `check_min_length`) populate error codes and actionable suggestion hints. + +### Added +- **Dependabot**: Added `.github/dependabot.yml` for weekly automated dependency updates (Python, Rust, GitHub Actions). +- **Error codes**: Every `FerroTAError` exception now carries a short code (e.g. `FTERR001`–`FTERR006`) for programmatic handling; see `ferro_ta.exceptions.ERROR_CODES`. +- **Observability / Logging** (`ferro_ta.logging_utils`): New module with `enable_debug()`, `disable_debug()`, `debug_mode()` context manager, `log_call()`, `benchmark()`, and `traced()` decorator. Re-exported from the `ferro_ta` namespace. +- **API discovery** (`ferro_ta.api_info`): New `ferro_ta.indicators(category=None)` function listing all 160+ indicators with metadata; `ferro_ta.info(func)` returning signature, docstring and parameter info. Re-exported from the `ferro_ta` namespace. +- **Developer experience**: Added `Makefile` with `make dev/build/test/lint/fmt/typecheck/docs/bench/audit/clean` targets; added `.devcontainer/devcontainer.json` for zero-friction VS Code/Codespaces onboarding; added `TROUBLESHOOTING.md` for common build issues. +- **Security**: Added `deny.toml` for `cargo-deny` license and advisory checking. +- **Test fixtures**: Added `tests/fixtures/ohlcv_daily.csv` (252-bar synthetic OHLCV dataset); added `tests/test_integration.py` with end-to-end indicator tests on the fixture. + +### Changed +- **Python 3.10 minimum:** Dropped support for Python 3.8 and 3.9. `requires-python` is now + `>=3.10` so optional dependencies (e.g. `mcp`) resolve correctly with uv/pip. CI, docs, + PLATFORMS.md, VERSIONING.md, CONTRIBUTING.md, and conda recipe updated accordingly. + +### Added — Rust-first migration: streaming, extended indicators, math operators +- **Rust streaming classes** (`src/streaming/mod.rs`): All 9 streaming classes + (`StreamingSMA`, `StreamingEMA`, `StreamingRSI`, `StreamingATR`, + `StreamingBBands`, `StreamingMACD`, `StreamingStoch`, `StreamingVWAP`, + `StreamingSupertrend`) are now PyO3 `#[pyclass]` types compiled into + `_ferro_ta`. Zero Python overhead for bar-by-bar updates in live-trading use. + Python `streaming.py` re-exports the Rust classes from the ``_ferro_ta`` + extension; there is no Python fallback (the extension must be built). +- **Rust extended indicators** (`src/extended/mod.rs`): All 10 extended + indicators (VWAP, SUPERTREND, DONCHIAN, ICHIMOKU, PIVOT_POINTS, + KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) now + compute entirely in Rust. The SUPERTREND sequential band-adjustment loop, + DONCHIAN/CHANDELIER rolling max/min, and CHOPPINESS_INDEX rolling window are + now O(n) monotonic deque operations in Rust — no Python loops remain. +- **Rust rolling math operators** (`src/math_ops/mod.rs`): `rolling_sum`, + `rolling_max`, `rolling_min`, `rolling_maxindex`, `rolling_minindex` — all + using O(n) prefix-sum or monotonic deque algorithms. Python `SUM`, `MAX`, + `MIN`, `MAXINDEX`, `MININDEX` in `math_ops.py` now delegate to Rust. +- **`docs/rust_first.md`**: New Rust-first architecture policy document. + Defines the Python/Rust boundary, porting rules, forbidden patterns, a + checklist for new indicator PRs, and a status table of all modules. +- **`ferro_ta.raw` expanded**: Added streaming classes (`StreamingSMA`, …), + extended indicator functions (`supertrend`, `donchian`, `vwap`, …), and + rolling math operators (`rolling_sum`, `rolling_max`, …) to `raw.py`. +- **`docs/index.rst`**: Added link to `docs/rust_first.md`. + +### Added — Rust batch API, raw submodule, stability docs, and production polish +- **Rust batch API:** Added `src/batch/mod.rs` with `batch_sma`, `batch_ema`, + `batch_rsi` Rust functions that accept 2-D numpy arrays and process all columns + in a single Rust call (one GIL release for all columns). Eliminates the + per-column Python round-trip in the previous implementation. +- **Python batch fast path:** `ferro_ta.batch.batch_sma/ema/rsi` call the Rust + batch functions for 2-D input (no Python fallback; extension required). + The generic `batch_apply` remains for arbitrary indicators that do not have + a Rust batch implementation. +- **`ferro_ta.raw` submodule:** New `python/ferro_ta/raw.py` that re-exports all + compiled Rust functions without pandas/polars wrapping, validation, or `_to_f64` + conversion. Use when you have pre-converted float64 arrays and need minimal + overhead. Includes the new `batch_sma/ema/rsi` Rust functions. +- **`docs/stability.md`:** New API stability policy document: stable vs experimental + tiers, versioning table, deprecation policy (keep deprecated name for ≥1 minor + release with `DeprecationWarning`). +- **`docs/plans/2026-03-08-production-grade.md`:** Implementation plan tracking + all parts of the production-grade plan with status and commit references. +- **`ndarray` dependency:** Added `ndarray = "0.16"` to `Cargo.toml` to support + 2-D array operations in the batch Rust module. +- **`docs/index.rst`:** Added link to `docs/stability.md`. +- **CONTRIBUTING.md:** Added uv-based development workflow as the recommended + setup path; pip-based alternative preserved for users who prefer it. +- **RELEASE.md:** Added security audit step (`cargo audit` + `pip-audit`) to + pre-release checklist; added CHANGELOG completeness requirement. + +### Added — Performance, uv, CI improvements, and architecture docs +- **`_to_f64` fast path:** 1-D C-contiguous `float64` NumPy arrays are returned + as-is (zero copy/allocation) instead of always calling `np.ascontiguousarray`. +- **polars zero-copy result:** `polars_wrap` now builds `pl.Series` from the + NumPy buffer via `pl.Series(name, np.asarray(result))` instead of the O(n) + `.tolist()` path, improving polars throughput for all indicators. +- **uv project manager support:** Added `[tool.uv]` section to `pyproject.toml` + with `dev-dependencies`; added a `dev` extra to `[project.optional-dependencies]`. + Development workflow: `uv sync --extra dev` then `uv run pytest tests/`. +- **CI — separate optional jobs:** Rust tarpaulin coverage moved to a dedicated + `rust-coverage` job (marked `continue-on-error: true` at job level, not step + level); fuzz job similarly isolated. All required CI steps are in blocking + jobs. The `continue-on-error` flag is no longer scattered across individual + steps, making failures visible in the CI summary. +- **CI — uv in lint/typecheck/audit:** `lint`, `typecheck`, and `audit` jobs + install uv and run tools via `uv run --with `. +- **Docs — `docs/architecture.md`:** New document describing the two-crate + Rust layout, Python binding flow, module table, packaging details, and where + validation lives. +- **Docs — `docs/performance.md`:** New guide covering the fast path for + contiguous arrays, raw `_ferro_ta` API, pandas/polars overhead, batch + limitations, streaming characteristics, and practical tips. +- **Docs — `docs/index.rst`:** Added links to architecture and performance docs. + +### Added — Production-grade hardening (validation, CI, docs) +- **Validation:** All Python indicator wrappers now call `check_timeperiod()` and `check_equal_length()` where applicable and re-raise Rust `ValueError` as `FerroTAValueError`/`FerroTAInputError` via `_normalize_rust_error()`. New helper `check_min_length()` in `ferro_ta.exceptions`. +- **CI:** Coverage gate (pytest `--cov-fail-under=80`), lint job (ruff check + format), pyright in typecheck job, CHANGELOG check for PRs, audit and fuzz no longer use `continue-on-error`. +- **Docs:** `docs/error_handling.rst`, `docs/api/exceptions.rst`, CONTRIBUTING updated for modular Rust layout (`src/pattern/mod.rs` + per-pattern files), Sphinx `release` from `FERRO_TA_VERSION` env. +- **Tests:** `tests/test_validation.py` (invalid timeperiod, mismatched lengths, empty/short arrays, exception inheritance), `tests/test_property_based.py` (Hypothesis), hypothesis optional dependency. +- **Tooling:** Ruff and pre-commit config (`.pre-commit-config.yaml`), mypy `warn_return_any = true`, pyright in CI, RELEASE.md and SECURITY.md updated. + +### Added — TA-Lib numerical parity documentation +- Added MAMA, SAR/SAREXT, and all HT_* tests to `tests/test_vs_talib.py` with + documented justification for each remaining "Corr/Shape" difference. +- `issues/Stages1-10.md` created with known-difference table for MAMA, SAR, + SAREXT, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDLINE, HT_TRENDMODE. + +### Added — Pure Rust core library +- New Cargo workspace: root `Cargo.toml` declares workspace members `[".","crates/ferro_ta_core"]`. +- `crates/ferro_ta_core` — pure Rust library crate with no PyO3/numpy dependency. +- Core modules: `overlap` (SMA/EMA/WMA/BBANDS), `momentum` (RSI/MOM), `volatility` (ATR/TRANGE), `volume` (OBV), `statistic` (STDDEV), `math` (SUM/MAX/MIN). +- `cargo test -p ferro_ta_core` passes (12 tests). +- CI `rust-core` job: `cargo build -p ferro_ta_core && cargo test -p ferro_ta_core`. +- README and CONTRIBUTING describe the two-layer architecture. + +### Added — Batch execution API +- New `ferro_ta.batch` module: `batch_sma`, `batch_ema`, `batch_rsi`, `batch_apply`. +- Accepts 2-D `(n_samples × n_series)` arrays; returns same shape. +- 1-D input falls back to single-series behaviour (backward compatible). +- Exported from `ferro_ta.__init__`; documented in `docs/batch.rst`. + +### Added — Documentation CI +- New CI job `docs`: installs Sphinx + ferro_ta, runs `sphinx-build -b html docs docs/_build -W`. +- `docs/batch.rst` and `docs/api/batch.rst` added; linked from `docs/index.rst`. +- Feature list in `docs/index.rst` updated to mention batch API and Rust core. + +### Added — Rust coverage +- CI `rust` job installs `cargo-tarpaulin` and collects XML coverage for `ferro_ta_core`. +- Coverage artifact `rust-coverage` uploaded per-run. +- CONTRIBUTING updated with `cargo tarpaulin` instructions. + +### Added — Community governance (issues/ directory) +- `issues/Stages1-10.md` — full issue text for stages 1–10 (linked from ROADMAP.md). +- `issues/Stages11-20.md` — stage overview for stages 11–20. + +### Added — Release and versioning playbook +- `RELEASE.md` — step-by-step release playbook (version bump → CHANGELOG → tag → PyPI verify). +- CI `version-check` job: fails if `Cargo.toml` and `pyproject.toml` versions diverge. +- `CONTRIBUTING.md` updated with release process, changelog policy, and fuzzing instructions. + +### Added — Optional GPU backend (PyTorch) +- `ferro_ta.gpu` module: `sma`, `ema`, `rsi` — GPU-accelerated when PyTorch is available. +- `ferro_ta[gpu]` optional extra in `pyproject.toml`. +- `docs/gpu-backend.md` — design doc with scope, limitations, and benchmark table. +- `benchmarks/bench_gpu.py` — CPU vs GPU benchmark script. + +### Added — WASM binding expansion +- WASM `macd()` added to `wasm/src/lib.rs` (7 indicators total). +- CI WASM job builds package and uploads `wasm-pkg` artifact. +- `wasm/README.md` updated with Node.js + browser examples and CI artifact docs. + +### Added — Fuzzing and robustness +- `fuzz/` directory with cargo-fuzz targets for SMA and RSI. +- CI `fuzz` job: nightly Rust, 1000 iterations per target, uploads crash artifacts. +- Fuzzing instructions added to `CONTRIBUTING.md`. + +### Added — Indicator pipeline / composition API +- `ferro_ta.pipeline` module: `Pipeline` class, `make_pipeline` factory. +- Chain multiple indicators; results returned as a named dictionary. +- Supports multi-output indicators (BBANDS, MACD) via `output_keys`. + +### Added — Polars integration +- Transparent `polars.Series` support via `polars_wrap` decorator in `_utils.py`. +- `ferro_ta[polars]` optional extra in `pyproject.toml`. +- Polars Series in → Polars Series out; NumPy path unchanged. + +### Added — Configuration and defaults management +- `ferro_ta.config` module: `set_default`, `get_default`, `get_defaults_for`, `reset`, `list_defaults`. +- `Config` context manager for temporary parameter overrides. +- Thread-local storage — safe for concurrent tests. + +### Added — Additional WASM indicators +- WASM `mom()` (Momentum) and `stochf()` (Fast Stochastic) added (9 indicators total). +- Tests for both new indicators in `wasm/src/lib.rs`. +- `wasm/README.md` updated with expanded indicator table. + +### Added — Jupyter notebook examples +- `examples/quickstart.ipynb` — core API tour (SMA, RSI, MACD, BBANDS, batch, pipeline, pandas). +- `examples/streaming.ipynb` — streaming bar-by-bar API demonstration. +- `examples/backtesting.ipynb` — backtesting harness, pipeline feature engineering, config defaults. +- `examples/README.md` — index of all notebooks with run instructions. + +### Added — v1.0 preparation and API stability +- `VERSIONING.md` updated with API stability guarantees and compatibility matrix. +- `ROADMAP.md` updated: stages 15–20 marked Done. +- README updated with Pipeline, Polars, and Config API sections. + +### Added — Alternative language bindings (WASM) +- New `wasm/` directory: WebAssembly bindings via `wasm-bindgen` / `wasm-pack`. +- Exposes `sma`, `ema`, `bbands`, `rsi`, `atr`, `obv` for Node.js and browsers. +- `wasm/README.md` — build & usage instructions; `wasm/package.json`. +- CI job `wasm` builds and tests the WASM crate with `wasm-pack test --node`. + +### Added — Distribution & packaging maturity +- Python 3.13 added to CI test matrix. +- `conda/meta.yaml` — Conda recipe for conda-forge / local channel builds. +- Supported platforms documented in `PLATFORMS.md`. + +### Added — Type stubs & typing +- `python/ferro_ta/py.typed` marker added (PEP 561 compliance). +- `pyproject.toml` `[tool.mypy]` section added for IDE / CI use. +- `Typing :: Typed` PyPI classifier present in `pyproject.toml`. + +### Added — Error model & validation +- `ferro_ta.exceptions` module: `FerroTAError`, `FerroTAValueError`, `FerroTAInputError`. +- Validation helpers: `check_timeperiod`, `check_equal_length`, `check_finite`. +- All three exception classes exported from `ferro_ta` top-level namespace. + +### Added — Backtesting utilities +- `ferro_ta.backtest` module: `backtest()` entry point, `BacktestResult` container. +- Built-in strategies: `rsi_strategy` (RSI 30/70) and `sma_crossover_strategy`. +- Clear scope note: "minimal harness for testing strategies." + +### Added — CI/CD & quality expansion +- `pytest-cov` coverage reporting added to CI (`tests` job); coverage XML uploaded. +- `CHANGELOG.md` (this file). +- `VERSIONING.md` — semantic versioning policy and release playbook. + +### Added — Plugin / extension system +- `ferro_ta.registry` module: `register`, `unregister`, `get`, `run`, `list_indicators`. +- All built-in indicators auto-registered at import time. +- `FerroTARegistryError` raised for unknown indicator names. + +--- + +[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.6...HEAD +[1.0.6]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.4...v1.0.6 +[1.0.4]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.3...v1.0.4 +[1.0.3]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/pratikbhadane24/ferro-ta/releases/tag/v1.0.0 diff --git a/ferro-ta-main/CODE_OF_CONDUCT.md b/ferro-ta-main/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5c2eb52 --- /dev/null +++ b/ferro-ta-main/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainer at **pratikbhadane24@gmail.com**. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/ferro-ta-main/CONTRIBUTING.md b/ferro-ta-main/CONTRIBUTING.md new file mode 100644 index 0000000..cfbda68 --- /dev/null +++ b/ferro-ta-main/CONTRIBUTING.md @@ -0,0 +1,492 @@ +# Contributing to ferro-ta + +Thank you for your interest in contributing to **ferro-ta**! This guide explains how to add new candlestick patterns and other indicators. + +## Prerequisites + +- Rust toolchain (stable, ≥ 1.70) +- **Python 3.10–3.13** (PyO3 supports up to 3.13; for 3.14+ use a separate venv with an older interpreter) +- [maturin](https://www.maturin.rs/) (`pip install maturin`) +- numpy (`pip install numpy`) +- pytest (`pip install pytest`) + +## Recommended: set up with uv (fast, reproducible) + +[uv](https://docs.astral.sh/uv/) is the recommended development tool for ferro-ta. +It handles virtual environments, dependency locking, and running commands: + +```bash +# Install uv (once) +pip install uv # or: curl -Lsf https://astral.sh/uv/install.sh | sh + +# Sync dev environment (creates .venv and installs all dev deps) +uv sync --extra dev + +# Build the Rust extension and install in the current env +uv run maturin build --release --out dist +pip install dist/*.whl + +# Run tests +uv run pytest tests/unit/ tests/integration/ + +# Run linter +uv run ruff check python/ tests/ + +# Run type checker +uv run mypy python/ferro_ta --ignore-missing-imports +``` + +## Git hooks and pre-push checks + +Install the repo-managed git hooks after syncing your environment: + +```bash +make hooks +``` + +That installs both the existing `pre-commit` hook and a `pre-push` hook that +runs the local CI gate before anything is pushed. + +To run the same gate manually: + +```bash +make prepush +``` + +To run only part of it while iterating: + +```bash +make prepush CHECKS="version changelog python_lint" +``` + +The pre-push runner covers the basic required CI categories we can execute +locally: version/changelog checks, Rust fmt/clippy/core checks, Python +lint/typecheck/tests, docs, and WASM. It intentionally skips the multi-version +matrix, audit, and benchmark-regression jobs. + +## Alternative: set up with plain pip + +```bash +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +pip install maturin numpy pytest +maturin develop --release # Build and install in editable mode +``` + +--- + +## Adding a New Candlestick Pattern + +All candlestick patterns live in **`src/pattern/`** (Rust: `mod.rs` plus one `.rs` file per pattern) and **`python/ferro_ta/indicators/pattern.py`** (Python wrapper). + +### Step 1 — Implement the Rust function + +Add a new file `src/pattern/cdl_mypattern.rs` with your `#[pyfunction]`, or add the function to an existing pattern file. Register it in **`src/pattern/mod.rs`**. Open `src/pattern/mod.rs` to see how other patterns are declared and registered (e.g. `mod cdl_doji;` and `self::cdl_doji::cdl_doji` in `register()`). Then implement the logic in your new file (e.g. open `src/pattern/cdl_doji.rs` as a template) and add a new `#[pyfunction]` using the shared helper functions already available at the top of the file: + +| Helper | Description | +|---|---| +| `body_size(open, close)` | Absolute body size | +| `upper_shadow(open, high, close)` | Upper shadow length | +| `lower_shadow(open, low, close)` | Lower shadow length | +| `candle_range(high, low)` | Full candle range (high − low) | +| `is_bullish(open, close)` | `true` when close ≥ open | +| `is_bearish(open, close)` | `true` when close < open | + +**Template for a single-candle pattern** (save as `src/pattern/cdl_mypattern.rs` and add `mod cdl_mypattern;` plus the register call in `src/pattern/mod.rs`): + +```rust +#[pyfunction] +pub fn cdl_mypattern<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let opens = open.as_slice()?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = opens.len(); + if n != highs.len() || n != lows.len() || n != closes.len() { + return Err(PyValueError::new_err("arrays must have the same length")); + } + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(opens[i], closes[i]); + let range = candle_range(highs[i], lows[i]); + let lower = lower_shadow(opens[i], lows[i], closes[i]); + let upper = upper_shadow(opens[i], highs[i], closes[i]); + + // TODO: replace with your pattern conditions + if range > 0.0 && /* pattern conditions */ { + result[i] = 100; // bullish (use -100 for bearish) + } + } + Ok(result.into_pyarray(py)) +} +``` + +**Template for a multi-candle pattern** (adjust `i in K..n` for K-candle lookback): + +```rust +#[pyfunction] +pub fn cdl_mypattern<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let opens = open.as_slice()?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = opens.len(); + if n != highs.len() || n != lows.len() || n != closes.len() { + return Err(PyValueError::new_err("arrays must have the same length")); + } + let mut result = vec![0i32; n]; + for i in 2..n { // 3-candle: use 2..n; 2-candle: use 1..n + let (o1, h1, l1, c1) = (opens[i-2], highs[i-2], lows[i-2], closes[i-2]); + let (o2, h2, l2, c2) = (opens[i-1], highs[i-1], lows[i-1], closes[i-1]); + let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i] ); + + // TODO: add your multi-candle conditions here + if /* conditions */ { + result[i] = 100; // or -100 + } + } + Ok(result.into_pyarray(py)) +} +``` + +### Step 2 — Register the function + +In **`src/pattern/mod.rs`**, add `mod cdl_mypattern;` at the top with the other pattern modules, and in the `register()` function add `self::cdl_mypattern::cdl_mypattern` to the list of registered functions. + +### Step 3 — Add the Python wrapper + +Open `python/ferro_ta/indicators/pattern.py` and: + +1. Import the Rust function at the top: + ```python + from ferro_ta._ferro_ta import cdl_mypattern as _cdl_mypattern + ``` + +2. Add a typed Python wrapper: + ```python + def CDL_MYPATTERN( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + ) -> np.ndarray: + """One-line summary. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + return _cdl_mypattern(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)) + ``` + +3. Add `"CDL_MYPATTERN"` to the `__all__` list. + +### Step 4 — Export from the top-level package + +Open `python/ferro_ta/__init__.py` and add an import (the canonical source is +`ferro_ta.indicators.pattern`; old flat path `ferro_ta.pattern` still works via +backward-compat stub): + +```python +from ferro_ta.indicators.pattern import ( # noqa: F401 + # ... existing imports ... + CDL_MYPATTERN, +) +``` + +Also add `"CDL_MYPATTERN"` to `__all__`. + +### Step 5 — Write a test + +Add a test class to `tests/unit/test_ferro_ta.py`: + +```python +class TestCDLMyPattern: + def test_output_values(self): + result = CDL_MYPATTERN(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0, 100) for v in result) + + def test_detects_pattern(self): + """Craft minimal OHLC data that must match the pattern.""" + o = np.array([...]) + h = np.array([...]) + l = np.array([...]) + c = np.array([...]) + result = CDL_MYPATTERN(o, h, l, c) + assert result[-1] in (100, -100) +``` + +### Step 6 — Build and verify + +```bash +maturin develop --release +pytest tests/unit/test_ferro_ta.py -v -k mypattern +``` + +--- + +## Adding Other Indicators + +- **Overlap Studies** (MAs, bands): `src/overlap/` (e.g. `mod.rs`, `sma.rs`) + `python/ferro_ta/indicators/overlap.py` +- **Momentum Indicators**: `src/momentum/` + `python/ferro_ta/indicators/momentum.py` +- **Cycle Indicators**: `src/cycle/` + `python/ferro_ta/indicators/cycle.py` +- **Volatility / Volume / Statistics**: corresponding `src/*/` directories + `python/ferro_ta/indicators/*.py` files + +Each module has a `pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()>` at the bottom — add your `wrap_pyfunction!` call there. + +--- + +## Code Style + +- Rust: follow `rustfmt` defaults (`cargo fmt`). +- Python: follow PEP 8; use Ruff for lint and format (`ruff check`, `ruff format`). +- Every public Rust function needs a docstring above the `#[pyfunction]` attribute. +- Every Python wrapper must have a NumPy-style docstring with `Parameters` and `Returns` sections. + +## Validation and tests for new indicators + +All new indicators **must**: + +- Use the validation helpers in `ferro_ta.exceptions`: call `check_timeperiod()` for every period parameter and `check_equal_length()` for multi-array inputs (OHLCV) before calling the Rust extension. Wrap the Rust call in `try/except ValueError` and re-raise with `_normalize_rust_error(e)`. +- Have tests in `tests/unit/` (including at least one test for invalid parameters or edge cases where applicable). +- Update docstrings and type stubs (`python/ferro_ta/__init__.pyi`) when adding or changing the public API. + +## Running the Full Test Suite + +```bash +pytest tests/unit/ tests/integration/ -v +``` + +CI runs on Python 3.10–3.13 across Linux, macOS, and Windows. Please make sure your change passes on all targets locally before opening a pull request. + +## Pull Request Checklist + +- [ ] Rust code compiles without warnings (`cargo build --release`) +- [ ] All existing tests still pass +- [ ] New test(s) cover the added function(s) +- [ ] Python wrapper and `__all__` updated +- [ ] `__init__.py` re-exports updated +- [ ] Docstrings present in both Rust and Python +- [ ] No vulnerable dependencies introduced (CI runs `cargo audit` and `pip-audit`; critical/high should be addressed) + +--- + +## Architecture: Two-Layer Rust/Python Design + +ferro-ta uses a **workspace** with two Rust crates: + +| Crate | Path | Purpose | +|-------|------|---------| +| `ferro_ta` | `.` (root) | PyO3 `#[pyfunction]` wrappers — converts numpy ↔ `&[f64]`; builds the Python `.whl` | +| `ferro_ta_core` | `crates/ferro_ta_core/` | Pure Rust indicators — no PyO3 / numpy dependency | + +When adding a new indicator: + +1. Implement the algorithm in `crates/ferro_ta_core/src/.rs` with a unit test. +2. Add a thin `#[pyfunction]` wrapper in `src/.rs` (or the appropriate submodule under `src//`) that calls into the core. +3. Add the Python wrapper in `python/ferro_ta/indicators/.py` (or the appropriate + sub-package: `python/ferro_ta/data/`, `python/ferro_ta/analysis/`, `python/ferro_ta/tools/`). + +```bash +# Build and test only the core (no Python required) +cargo build -p ferro_ta_core +cargo test -p ferro_ta_core +``` + +### Python sub-package layout + +The `python/ferro_ta/` package is organized into sub-packages by concern. +Backward-compat stubs at the old flat paths (e.g. `ferro_ta.momentum`) re-export +from the new locations so existing code continues to work without changes. + +``` +python/ferro_ta/ +├── __init__.py # top-level re-exports and public API +├── core/ # Exceptions, configuration, registry, logging, raw FFI bindings +├── indicators/ # Technical indicators (momentum, overlap, volatility, volume, +│ # statistic, cycle, pattern, price_transform, math_ops, extended) +├── data/ # Streaming, batch, chunked, resampling, aggregation, adapters +├── analysis/ # Portfolio, backtest, regime, cross_asset, attribution, +│ # signals, features, crypto, options +├── tools/ # Visualisation, alerting, DSL, pipeline, workflow, +│ # api_info, GPU support +└── mcp/ # Model Context Protocol server +``` + +### Test directory layout + +``` +tests/ +├── conftest.py # shared fixtures (inherited by all sub-directories) +├── unit/ # pure unit tests and property-based tests +│ ├── test_ferro_ta.py +│ ├── test_coverage.py +│ ├── test_validation.py +│ ├── test_known_values.py +│ ├── test_property_based.py +│ ├── test_stages_*.py +│ └── test_math_ops_vs_numpy.py +├── integration/ # integration and comparison tests (vs TA-Lib, pandas-ta, ta) +│ ├── test_integration.py +│ ├── test_streaming_accuracy.py +│ ├── test_vs_talib.py +│ ├── test_vs_pandas_ta.py +│ └── test_vs_ta.py +└── benchmarks/ # benchmark tests are in top-level benchmarks/ +``` + + + +The root crate (`src/`) is organized by TA-Lib category: + +| Module | Path | Contents | +|--------|------|----------| +| `overlap` | `src/overlap/` | Overlap studies: SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, BBANDS, MACD, SAR, MAMA, SAREXT, MACDEXT, MIDPOINT, MIDPRICE, MA, MAVP | +| `momentum` | `src/momentum/` | Momentum: RSI, MOM, ROC, WILLR, AROON, CCI, MFI, STOCH, ADX, TRIX, etc. | +| `pattern` | `src/pattern/` | Candlestick patterns: CDLDOJI, CDLENGULFING, CDLHAMMER, … | +| `cycle` | `src/cycle/` | Cycle: HT_TRENDLINE, HT_DCPERIOD, HT_PHASOR, HT_SINE, HT_TRENDMODE | +| `volatility` | `src/volatility/` | ATR, NATR, TRANGE | +| `volume` | `src/volume/` | AD, ADOSC, OBV | +| `statistic` | `src/statistic/` | STDDEV, VAR, LINEARREG, BETA, CORREL, … | +| `price_transform` | `src/price_transform/` | AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE | + +Each module is a directory with `mod.rs` and one or more `.rs` files (e.g. `src/overlap/mod.rs`, `src/overlap/sma.rs`). + +**Modular layout:** Pattern recognition is split into `src/pattern/mod.rs` plus one file per pattern (e.g. `src/pattern/cdl_doji.rs`, `src/pattern/cdl_engulfing.rs`). Overlap and momentum use a similar directory layout. To add a new pattern: add a new `src/pattern/cdl_*.rs` file with your `#[pyfunction]` and register it in `src/pattern/mod.rs`. + +--- + +## Batch API + +The `ferro_ta.batch` module provides `batch_sma`, `batch_ema`, and `batch_rsi` +(Rust 2-D implementations) plus the generic `batch_apply(data, fn, **kwargs)`. +For a new indicator that does not have a dedicated Rust batch function, use +`batch_apply(data, YOUR_INDICATOR)`. + +--- + +## Running Rust Benchmarks + +```bash +# Compile benchmarks only (fast, used in CI) +cargo bench --no-run + +# Run benchmarks and get timings +cargo bench +``` + +Benchmarks are in `benches/indicators.rs` using [Criterion](https://github.com/bheisler/criterion.rs). + +--- + +## Rust Coverage + +```bash +# Install cargo-tarpaulin (one-time) +cargo install cargo-tarpaulin + +# Collect coverage for the core crate +cargo tarpaulin -p ferro_ta_core --out Html + +# Open htmlcov/index.html +``` + +--- + +## Type Checking (mypy) + +```bash +# Install mypy (one-time) +pip install mypy numpy + +# Run type checking +mypy python/ferro_ta --ignore-missing-imports + +# No errors should be reported. +``` + +Type stubs live in `python/ferro_ta/__init__.pyi`. Update them whenever you add a new +public function. + +--- + +## Release Process + +See [RELEASE.md](RELEASE.md) for the full step-by-step release playbook and +[PACKAGING.md](PACKAGING.md) for conda-forge submission and feedstock maintenance. (version bump → +changelog → tag → CI builds wheels → publish to PyPI). + +See [VERSIONING.md](VERSIONING.md) for the versioning policy (MAJOR/MINOR/PATCH rules, +supported Python version policy, and changelog maintenance requirements). + +### Changelog requirement + +Every PR that touches `src/`, `python/`, or `wasm/` **must** add an entry to the +`[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). Use the +[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format +(`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`). + +### Version consistency + +`Cargo.toml` and `pyproject.toml` must always carry the same `version` string. +CI enforces this with the `version-check` job — a PR that changes one but not the +other will fail CI. + +### Dependency audits + +CI runs **cargo audit** (Rust) and **pip-audit** (Python) in the `audit` job. PRs must +not introduce critical or high-severity vulnerabilities. If a dependency cannot be +updated immediately, document the accepted risk in the PR or in SECURITY.md. See +[SECURITY.md](SECURITY.md) for the full policy. + +### Fuzzing (robustness) + +Fuzz targets live in `fuzz/` (cargo-fuzz). To run fuzzing locally: + +```bash +# Install cargo-fuzz (one-time) +cargo install cargo-fuzz + +# Run the SMA fuzz target for 60 seconds +cargo fuzz run fuzz_sma -- -max_total_time=60 + +# Run the RSI fuzz target +cargo fuzz run fuzz_rsi -- -max_total_time=60 +``` + +Any crash found by the fuzzer is saved to `fuzz/artifacts//`. Open a bug report +with the reproducing input and the panic message. + + +--- + +## Getting Help + +If you have a question, found a bug, or want to suggest a new indicator: + +- **GitHub Discussions** — For questions, ideas, and general discussion, use our [Discussions](https://github.com/pratikbhadane24/ferro-ta/discussions) space: + - **Q&A** — Ask usage or API questions + - **Ideas** — Propose new features or indicators + - **Show & Tell** — Share strategies and projects built with ferro-ta + - **Announcements** — Follow for release notes and important updates +- **GitHub Issues** — For confirmed bugs and actionable feature requests, open an [issue](https://github.com/pratikbhadane24/ferro-ta/issues). +- **Security issues** — See [SECURITY.md](SECURITY.md) for responsible disclosure instructions. diff --git a/ferro-ta-main/Cargo.lock b/ferro-ta-main/Cargo.lock new file mode 100644 index 0000000..93b5e9c --- /dev/null +++ b/ferro-ta-main/Cargo.lock @@ -0,0 +1,868 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "ferro_ta" +version = "1.2.0" +dependencies = [ + "criterion", + "ferro_ta_core", + "log", + "ndarray", + "numpy", + "pyo3", + "pyo3-log", + "rayon", + "ta", +] + +[[package]] +name = "ferro_ta_core" +version = "1.2.0" +dependencies = [ + "criterion", + "multiversion", + "serde", + "serde_json", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "multiversion" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "target-features", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f1dee9aa8d3f6f8e8b9af3803006101bb3653866ef056d530d53ae68587191" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-log" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45192e5e4a4d2505587e27806c7b710c231c40c56f3bfc19535d0bb25df52264" +dependencies = [ + "arc-swap", + "log", + "pyo3", +] + +[[package]] +name = "pyo3-macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "ta" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226" + +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/ferro-ta-main/Cargo.toml b/ferro-ta-main/Cargo.toml new file mode 100644 index 0000000..14ef96a --- /dev/null +++ b/ferro-ta-main/Cargo.toml @@ -0,0 +1,52 @@ +[workspace] +members = [".", "crates/ferro_ta_core"] +exclude = ["fuzz"] +resolver = "2" + +[package] +name = "ferro_ta" +version = "1.2.0" +edition = "2021" +description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" +license = "MIT" +readme = "README.md" +repository = "https://github.com/pratikbhadane24/ferro-ta" +homepage = "https://github.com/pratikbhadane24/ferro-ta#readme" +documentation = "https://pratikbhadane24.github.io/ferro-ta/" +keywords = ["technical-analysis", "trading", "indicators", "finance", "ta-lib"] +categories = ["finance", "mathematics"] +publish = false + +[lib] +name = "ferro_ta" +crate-type = ["cdylib"] + +[dependencies] +# abi3-py310: build a single stable-ABI wheel that runs on CPython 3.10+ +# (including future 3.14+), instead of one wheel per minor version. +pyo3 = { version = "0.25", features = ["extension-module", "abi3-py310"] } +ta = "0.5.0" +numpy = "0.25" +# Must be < 0.17 while numpy 0.25 is used (numpy's IntoPyArray is for its own ndarray only). +ndarray = "0.16" +rayon = "1.10" +log = "0.4" +pyo3-log = "0.12" +# default-features = false so the `simd` toggle is forwarded explicitly via +# this crate's own `simd` feature (below). Without this, core's default `simd` +# would always be on and `--no-default-features` could never produce a true +# pure-scalar build (used by the SIMD benchmark baseline). +ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.2.0", default-features = false, features = ["serde"] } + +[dev-dependencies] +criterion = { version = "0.8", features = ["html_reports"] } + +[profile.release] +lto = true +codegen-units = 1 + +[features] +# SIMD runtime dispatch ON by default → published wheels ship the adaptive +# fast path with no extra flags. `--no-default-features` yields pure scalar. +default = ["simd"] +simd = ["ferro_ta_core/simd"] diff --git a/ferro-ta-main/GOVERNANCE.md b/ferro-ta-main/GOVERNANCE.md new file mode 100644 index 0000000..c5b6259 --- /dev/null +++ b/ferro-ta-main/GOVERNANCE.md @@ -0,0 +1,63 @@ +# Governance + +## Maintainers + +ferro-ta is currently maintained by: + +- **@pratikbhadane24** — project creator and lead maintainer + +## Decision Making + +Decisions about the project are made by the maintainers. For significant +changes (new indicator categories, API changes, roadmap priorities) we welcome +community discussion in GitHub Issues before implementation begins. + +For minor bug fixes, documentation improvements, and dependency updates, pull +requests may be merged once CI passes and at least one maintainer approves. + +For major features (new roadmap stages), an issue or discussion should +be opened first to agree on scope and approach. + +## How to Contribute + +See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on setting up a +development environment, coding style, and the pull request process. + +## How to Become a Maintainer + +Consistent, high-quality contributions over time may lead to an invitation to +become a maintainer. If you are interested, please reach out via a GitHub Issue +or by contacting the project at **pratikbhadane24@gmail.com**. + +## Call for Co-Maintainers + +ferro-ta is a growing library with 160+ indicators, an active roadmap, and a +community of traders and developers who depend on it. We are actively looking +for **co-maintainers** to help with: + +- Reviewing pull requests and triaging issues +- Adding new indicators and extending the Rust core +- Improving documentation and tutorials +- Managing CI, releases, and dependency updates + +**If you are interested**, please open a GitHub Discussion in the +[**Announcements → Co-maintainer interest**](https://github.com/pratikbhadane24/ferro-ta/discussions) +category, or reach out at **pratikbhadane24@gmail.com**. + +Ideal co-maintainers have: +- Familiarity with Rust and/or Python numerical computing +- Experience with open-source project workflows (PRs, issues, CI) +- Interest in algorithmic trading or quantitative finance + +We value contributions at all experience levels — there is no minimum bar +beyond genuine interest and consistent engagement. + +## Code of Conduct + +All contributors and community members are expected to follow the +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Roadmap + +The project roadmap is documented in [ROADMAP.md](ROADMAP.md). Stages 1–20 +define the scope of planned work; the current focus is indicated there. diff --git a/ferro-ta-main/LICENSE b/ferro-ta-main/LICENSE new file mode 100644 index 0000000..835e937 --- /dev/null +++ b/ferro-ta-main/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 ferro-ta contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ferro-ta-main/Makefile b/ferro-ta-main/Makefile new file mode 100644 index 0000000..5015682 --- /dev/null +++ b/ferro-ta-main/Makefile @@ -0,0 +1,72 @@ +# ferro-ta development Makefile +# Usage: make + +.PHONY: help dev build test lint typecheck fmt docs clean bench version audit prepush hooks + +# Default target +help: + @echo "ferro-ta development targets:" + @echo "" + @echo " make dev Install dev dependencies (maturin + test extras)" + @echo " make build Build and install the Rust extension in dev mode" + @echo " make test Run the full Python test suite with coverage" + @echo " make lint Run ruff linter on python/ and tests/" + @echo " make fmt Run rustfmt + ruff formatter" + @echo " make typecheck Run mypy + pyright type checkers" + @echo " make docs Build the Sphinx documentation" + @echo " make bench Run Rust criterion benchmarks (ferro_ta_core)" + @echo " make version Bump tracked version strings (set VERSION=X.Y.Z)" + @echo " make audit Run cargo-audit + pip-audit" + @echo " make prepush Run the local pre-push CI gate (set CHECKS='version rust_fmt' to scope it)" + @echo " make hooks Install pre-commit and pre-push git hooks" + @echo " make clean Remove build artefacts" + +dev: + pip install uv + uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml \ + sphinx sphinx-rtd-theme ruff mypy pyright pre-commit + +build: + maturin develop --release + +test: build + pytest tests/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65 + +lint: + uv run --with ruff ruff check python/ tests/ + uv run --with ruff ruff format --check python/ tests/ + +fmt: + cargo fmt --all + uv run --with ruff ruff format python/ tests/ + +typecheck: + uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary + uv run --with pyright pyright python/ferro_ta + +docs: + pip install sphinx sphinx-rtd-theme + sphinx-build -b html docs docs/_build --keep-going + +bench: + cargo bench -p ferro_ta_core + +version: + @test -n "$(VERSION)" || (echo "Usage: make version VERSION=X.Y.Z" && exit 1) + python3 scripts/bump_version.py "$(VERSION)" + +audit: + cargo audit + uv run --with pip-audit pip-audit + +prepush: + bash scripts/pre_push_checks.sh $(CHECKS) + +hooks: + uv run --with pre-commit pre-commit install --hook-type pre-commit --hook-type pre-push + +clean: + cargo clean + rm -rf dist/ docs/_build/ coverage.xml .coverage *.egg-info + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -name "*.so" -delete 2>/dev/null || true diff --git a/ferro-ta-main/PACKAGING.md b/ferro-ta-main/PACKAGING.md new file mode 100644 index 0000000..1d2cd4e --- /dev/null +++ b/ferro-ta-main/PACKAGING.md @@ -0,0 +1,28 @@ +# Packaging and distribution + +This document describes how ferro-ta is packaged and published. + +## PyPI (pip) + +Wheels are built by CI on release (see [RELEASE.md](RELEASE.md)). +Release publishing currently targets CPython 3.10, 3.11, 3.12, and 3.13 on: + +- Linux x86_64 (`manylinux_2_17` / `manylinux2014`) +- macOS universal2 (covers Intel and Apple Silicon) +- Windows x86_64 + +Each release also publishes a source distribution (`sdist`) so compatible +environments outside the wheel matrix can still build from source. + +Publishing uses PyPI Trusted Publishing via GitHub OIDC; no long-lived PyPI API +token is required. + +Supported platforms and Python versions are documented in [PLATFORMS.md](PLATFORMS.md). + +## npm (WASM) + +The Node.js / browser WASM package is published to npm by the **wasm-publish** workflow on release. + +## crates.io (Rust) + +The pure-Rust library `ferro_ta_core` is published to crates.io by the CI job **publish-cratesio** on release. diff --git a/ferro-ta-main/PERFORMANCE_ROADMAP.md b/ferro-ta-main/PERFORMANCE_ROADMAP.md new file mode 100644 index 0000000..cfffbdf --- /dev/null +++ b/ferro-ta-main/PERFORMANCE_ROADMAP.md @@ -0,0 +1,140 @@ +# ferro-ta Performance Roadmap + +## Goal: 100x Faster Than Tulipy for Every Indicator + +This document tracks the path from current performance to the 100x target. + +--- + +## Current State (10k bars, median µs) + +| Indicator | ferro_ta | Tulipy | Ratio (tu/ft) | Status | +|-----------|--------:|-------:|:-------------:|--------| +| SMA | 186 | 84 | 0.45x | ❌ Tulipy faster | +| EMA | 90 | 89 | 0.99x | 🔄 Parity | +| RSI | 112 | 91 | 0.81x | 🔄 Near parity | +| MACD | 135 | 99 | 0.73x | 🔄 Near parity | +| BBANDS | 99 | 96 | 0.97x | 🔄 Parity | +| ATR | 113 | 103 | 0.91x | 🔄 Near parity | +| CCI | 147 | 126 | 0.86x | 🔄 Near parity | +| WILLR | 167 | 119 | 0.71x | 🔄 Near parity | +| OBV | 88 | 83 | 0.94x | 🔄 Parity | +| ADX | 165 | 126 | 0.76x | 🔄 Near parity | +| MFI | 111 | 122 | 1.10x | ✅ ferro_ta faster | +| STOCH | 176 | 144 | 0.82x | 🔄 Near parity | + +**vs `ta` library** (Python loops): ferro_ta is already **150–350x faster** for slow indicators (ATR, CCI, ADX, MFI). + +--- + +## Why ferro_ta Doesn't Beat Tulipy Yet + +Both ferro_ta and Tulipy are Rust/C extensions processing 10,000 `f64` values. The bottlenecks are: + +1. **FFI overhead dominates at 10k bars** — Python→Rust call overhead is ~50µs fixed cost +2. **Array allocation**: ferro_ta pads NaN values; Tulipy truncates (saves allocation) +3. **SIMD**: Tulipy's C code uses auto-vectorization; ferro_ta Rust needs explicit SIMD + +--- + +## Optimization Plan + +### Phase 1: Eliminate FFI Overhead (Target: 2x improvement) + +**Problem**: Each Python call into Rust costs ~50µs regardless of array size. + +**Solutions**: +- [ ] Batch API: `compute_many([("SMA", close, 20), ("EMA", close, 14)])` — single FFI call +- [ ] Buffer reuse: accept pre-allocated output arrays to avoid allocation round-trips +- [ ] NumPy zero-copy: use `PyReadonlyArray` in pyo3 to avoid copies on input + +**Expected gain**: 2x for small arrays (<1k bars), 1.3x for 10k bars. + +### Phase 2: SIMD Auto-Vectorization (Target: 3x improvement) + +**Problem**: Rust scalar loops vs SIMD C in Tulipy. + +**Solutions**: +- [ ] Use `std::simd` (portable SIMD) for rolling sum accumulation (SMA, WMA) +- [ ] Use `packed_simd2` for element-wise operations (ADD, SQRT, LOG10, price transforms) +- [ ] Enable `target-cpu=native` in `.cargo/config.toml` for AVX2/AVX-512 + +```toml +# .cargo/config.toml +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=native"] +``` + +**Expected gain**: 3-5x for vectorizable indicators (SMA, WMA, price transforms, math ops). + +### Phase 3: Algorithm-Level Optimizations (Target: 5-10x improvement) + +#### SMA — O(n) running sum +Current: recomputes each window. +Target: single-pass running sum (already done in Rust — verify SIMD path is hit). + +#### BBANDS — Welford's algorithm +Current: compute mean, then variance in two passes. +Target: Welford's online algorithm — single pass, better cache utilization. + +#### ATR/ADX — Avoid redundant True Range calculations +Current: ATR → ADX each compute TR independently. +Target: Compute TR once, share with ATR, NATR, +DI, -DI, ADX in a single pass. + +#### MACD — Reuse EMA computations +Current: Compute fast EMA and slow EMA separately. +Target: Single function computes both EMAs in one pass. + +#### Candlestick Patterns — Batch lookup table +Current: Sequential condition checks per bar. +Target: Pre-compute body/shadow ratios, vectorized pattern matching. + +### Phase 4: Streaming Precomputation (Target: 100x for incremental updates) + +For real-time systems that update one bar at a time: + +- [ ] `StreamingSMA` already O(1) per update — document and benchmark vs batch +- [ ] `StreamingEMA` α * new + (1-α) * prev — single multiply + add +- [ ] `StreamingBBands` — use Welford's online variance +- [ ] `StreamingRSI` — Wilder's smoothing: single multiply per update + +**At 100k bars, streaming 1 bar at a time is O(n) vs O(n) batch, but with near-zero latency per update.** + +Benchmark: batch 100k bars vs 100k × streaming 1 bar: + +``` +ferro_ta batch SMA(100k): ~1.8ms +ferro_ta streaming SMA(100k): ~0.5ms total (5µs per bar × 100k = too slow) +``` + +Streaming becomes 100x advantage when: +- You only need the latest value (no history needed) +- Input arrives one bar at a time (WebSocket price feed) + +--- + +## Measurement Methodology + +All benchmarks use: +- `pytest-benchmark` with `pedantic()` mode +- 5 iterations × 20 rounds × 2 warmup rounds +- Median timing (not mean) to exclude JIT warmup +- C-contiguous `float64` arrays +- 10,000 bars for main benchmarks, 100,000 for scaling tests + +Machine: Apple M-series / Intel x86_64 (note: results vary significantly by CPU) + +--- + +## Tracking Progress + +Run `pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json` +and commit `results.json` to track regression over time. + +--- + +## References + +- [Tulipy source](https://github.com/cirla/tulipy) — C with auto-vectorization +- [Rust SIMD Guide](https://doc.rust-lang.org/std/simd/index.html) +- [pyo3 zero-copy arrays](https://pyo3.rs/v0.22.0/numpy) diff --git a/ferro-ta-main/PLATFORMS.md b/ferro-ta-main/PLATFORMS.md new file mode 100644 index 0000000..3829077 --- /dev/null +++ b/ferro-ta-main/PLATFORMS.md @@ -0,0 +1,76 @@ +# Supported Platforms & Python Versions + +## Python versions + +| Python | Status | +|--------|--------| +| 3.13 | ✅ Supported (tested in CI) | +| 3.12 | ✅ Supported (tested in CI) | +| 3.11 | ✅ Supported (tested in CI) | +| 3.10 | ✅ Supported (tested in CI) | +| < 3.10 | ❌ Not supported | + +We follow the [NEP 29](https://numpy.org/neps/nep-0029-deprecation-policy.html) +deprecation schedule: Python versions that have reached end-of-life are dropped +in the next minor release of ferro-ta. + +## Operating systems & architectures + +Pre-compiled wheels are published to PyPI for the following targets: + +| OS | Architecture | Notes | +|---------|-----------------|-------| +| Linux | x86_64 (manylinux2014 / `manylinux_2_17`) | Pre-compiled wheel | +| macOS | universal2 | One wheel covers Intel + Apple Silicon | +| Windows | x86_64 | | + +Wheel releases target CPython 3.10, 3.11, 3.12, and 3.13. A source +distribution is also published so other compatible environments can build from +source. + +> **Note:** Python 3.14+ is not yet tested. Set +> `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` to attempt a build on a newer +> interpreter and report any issues. + +## Installation + +### pip (recommended) + +```bash +pip install ferro-ta +``` + +No C-compiler required on the wheel targets listed above. + +### conda / conda-forge + +A Conda recipe is available in `conda/meta.yaml`. To build locally, see +[PACKAGING.md](PACKAGING.md). Quick start: + +```bash +conda install conda-build +conda build conda/ +conda install --use-local ferro_ta +``` + +Once submitted to conda-forge the package will be installable via: + +```bash +conda install -c conda-forge ferro_ta +``` + +## Source build + +If no wheel is available for your platform, pip will attempt a source build: + +```bash +# Requires Rust (stable toolchain) and maturin +pip install maturin +pip install ferro-ta --no-binary ferro-ta +``` + +## Known limitations + +- WASM binding: full feature parity with 200+ exports including all TA-Lib indicators, candlestick patterns, streaming API, options, futures, and backtesting (see `wasm/README.md`). +- Python 3.14+: untested; may work with `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`. +- 32-bit platforms: not officially supported; source builds may succeed. diff --git a/ferro-ta-main/README.md b/ferro-ta-main/README.md new file mode 100644 index 0000000..6a0c491 --- /dev/null +++ b/ferro-ta-main/README.md @@ -0,0 +1,139 @@ +
+ +# ⚡ ferro-ta + +### Rust-powered Python technical analysis with a TA-Lib-compatible API + +**Focused on one primary job: fast, reproducible technical analysis for Python users who want TA-Lib-style ergonomics without native build friction.** + +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/pratikbhadane24/ferro-ta/HEAD?labpath=examples%2Fquickstart.ipynb) +[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/pratikbhadane24/ferro-ta/blob/main/examples/quickstart.ipynb) +[![Documentation](https://img.shields.io/badge/docs-github.io-blue)](https://pratikbhadane24.github.io/ferro-ta/) + +
+ +--- + +> `ferro-ta` is a Rust-backed Python technical analysis library for NumPy-first workloads. It keeps TA-Lib-style ergonomics, ships pre-built wheels on supported targets, and publishes reproducible benchmark artifacts instead of blanket speed claims. + +## 🚀 What ferro-ta is + +| | TA-Lib | ferro-ta | +|---|---|---| +| **Primary product** | C-backed Python TA library | Rust-backed Python TA library | +| **API shape** | `talib.SMA(close, 20)` | `ferro_ta.SMA(close, 20)` | +| **Installation** | Often requires native/system setup | Pre-built wheels on supported targets | +| **Scope** | Technical indicators | Technical indicators first; other tooling is optional and secondary | + +## ⚡ Benchmark evidence + +The latest checked-in TA-Lib comparison artifact uses contiguous `float64` +arrays at 10k and 100k bars on an `Apple M3 Max`, `CPython 3.13.5`, and `Rust +1.91.1`. + +- `ferro-ta` achieves competitive parity with TA-Lib, winning on 7 of 12 tested indicators at 100k bars (5 of 12 at 10k bars). +- Strong performance wins at 100k bars include `MFI` (`3.25×`), `WMA` (`2.20×`), `BBANDS` (`1.97×`), and `SMA` (`1.93×`) vs TA-Lib. +- TA-Lib maintains performance advantages on `STOCH` and `ADX`; `EMA`, `ATR`, and `OBV` are statistical ties. +- Compared to pure-Python libraries like Tulipy, `ferro-ta` provides 150-350x speedups through Rust-optimized implementations. + +See the benchmark methodology and artifacts: + +- [benchmarks/README.md](benchmarks/README.md) +- [benchmarks/artifacts/latest/](benchmarks/artifacts/latest/) +- [docs/benchmarks.rst](docs/benchmarks.rst) + +## 🎯 Core capabilities + +- 160+ indicators with a TA-Lib-style public API. +- Batch and streaming APIs for multi-series and bar-by-bar workloads. +- NumPy-first execution with pandas and polars adapters. +- Pre-built wheels on the supported Python and OS matrix. +- Type stubs, error codes, examples, and reproducible benchmarks. + +Adjacent and experimental surfaces such as derivatives analytics, MCP, GPU, +plugins, and WASM remain opt-in and secondary to the core TA library story. + +## 📦 Installation + +```bash +pip install ferro-ta +``` + +Optional extras: + +```bash +pip install "ferro-ta[pandas]" # pandas.Series support +pip install "ferro-ta[polars]" # polars.Series support +pip install "ferro-ta[gpu]" # PyTorch-backed GPU helpers +pip install "ferro-ta[options]" # derivatives analytics helpers +pip install "ferro-ta[mcp]" # MCP server for agent/tool clients +pip install "ferro-ta[all]" # most optional extras (excluding gpu) +``` + +## ⚡ Quick start + +```python +import numpy as np +from ferro_ta import SMA, EMA, RSI, MACD, BBANDS + +close = 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]) + +sma = SMA(close, timeperiod=5) +ema = EMA(close, timeperiod=5) +rsi = RSI(close, timeperiod=14) +macd_line, signal, histogram = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9) +upper, middle, lower = BBANDS(close, timeperiod=5, nbdevup=2.0, nbdevdn=2.0) +``` + +## 📊 TA-Lib compatibility + +- `ferro-ta` implements 100% of TA-Lib's function set (`162+` indicators). +- Most functions are marked `Exact` or `Close`; the remaining notable non-exact categories are the Hilbert cycle indicators plus `MAMA`, `SAR`, and `SAREXT`. +- The full parity matrix and coverage summary now live in [TA_LIB_COMPATIBILITY.md](TA_LIB_COMPATIBILITY.md). + +Migration and compatibility references: + +- [docs/migration_talib.rst](docs/migration_talib.rst) +- [docs/compatibility/talib.md](docs/compatibility/talib.md) +- [docs/support_matrix.rst](docs/support_matrix.rst) + +## 🗺️ Docs map + +Core guides: + +- [docs/quickstart.rst](docs/quickstart.rst) +- [docs/migration_talib.rst](docs/migration_talib.rst) +- [docs/support_matrix.rst](docs/support_matrix.rst) +- [PLATFORMS.md](PLATFORMS.md) + +Evidence and APIs: + +- [benchmarks/README.md](benchmarks/README.md) +- [docs/batch.rst](docs/batch.rst) +- [docs/streaming.rst](docs/streaming.rst) +- [docs/derivatives.rst](docs/derivatives.rst) + +Optional and experimental surfaces: + +- [docs/mcp.md](docs/mcp.md) +- [docs/adjacent_tooling.rst](docs/adjacent_tooling.rst) +- [docs/plugins.rst](docs/plugins.rst) +- [wasm/README.md](wasm/README.md) + +Project and release docs: + +- [CONTRIBUTING.md](CONTRIBUTING.md) +- [CHANGELOG.md](CHANGELOG.md) +- [VERSIONING.md](VERSIONING.md) +- [RELEASE.md](RELEASE.md) + +## 🛠️ Development + +```bash +uv sync --extra dev +uv run pytest tests/unit tests/integration +uv run maturin build --release --out dist +``` + +More setup details live in [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/ferro-ta-main/RELEASE.md b/ferro-ta-main/RELEASE.md new file mode 100644 index 0000000..52e2a5d --- /dev/null +++ b/ferro-ta-main/RELEASE.md @@ -0,0 +1,212 @@ +# Release Playbook + +This document describes the step-by-step process for cutting a new **ferro-ta** release. +Follow every step in order to produce a consistent, reproducible release. + +For the packaging and release overview, see [PACKAGING.md](PACKAGING.md). + +--- + +## Publish matrix (all automatic on release) + +| Artifact | How | +|-------------|-----| +| **PyPI** | CI job `publish` using PyPI Trusted Publishing (OIDC) | +| **npm (WASM)** | Workflow `wasm-publish`| +| **crates.io** | CI job `publish-cratesio` | + +PyPI releases are expected to include: + +- Wheels for CPython 3.10, 3.11, 3.12, and 3.13 +- Linux x86_64 (`manylinux_2_17`) +- macOS universal2 +- Windows x86_64 +- One source distribution (`sdist`) + +--- + +## Pre-release checklist + +Before starting a release: + +- [ ] All CI checks are green on `main`: Rust (fmt, clippy), tests (with coverage gate), + lint (ruff), typecheck (mypy, pyright), docs (Sphinx), WASM, audit (cargo-audit, + pip-audit), fuzz (no crashes). +- [ ] **Security audit clean:** Run `cargo audit` and `pip-audit` locally and confirm + no high/critical vulnerabilities. Address any findings before tagging. + ```bash + cargo audit + pip-audit # or: uv run --with pip-audit pip-audit + ``` +- [ ] No open blocking issues or PRs that must land first. +- [ ] `CHANGELOG.md` has a `## [X.Y.Z]` section (not `[Unreleased]`) with all + changes since the last release documented under `### Added`, `### Changed`, + `### Fixed`, `### Removed` headings. +- [ ] Public docs match the release: `docs/conf.py`, `docs/changelog.rst`, and + `docs/support_matrix.rst` reflect the version and current support status. + +--- + +## Step 1 — Decide the version number + +Follow [Semantic Versioning 2.0.0](https://semver.org/) and the policy in +[VERSIONING.md](VERSIONING.md): + +| Change type | Version component to bump | +|---|---| +| Breaking API change (indicator removed, parameter renamed, return type changed) | **MAJOR** | +| New indicators, features, or bindings (backward-compatible) | **MINOR** | +| Bug fixes, performance, docs-only, dependency bumps | **PATCH** | + +Example: current version is `0.1.0` and you are adding new indicators → new version is `0.2.0`. + +--- + +## Step 2 — Sync version everywhere + +These files must carry **the same version string** (e.g. `0.2.0`). The easiest +way to do that is: + +```bash +python3 scripts/bump_version.py 0.2.0 +python3 scripts/bump_version.py --check +``` + +That script updates the tracked release-version carriers for you. + +Files covered by the bump script: + +| File | Location | +|------|----------| +| `Cargo.toml` | Root (source of truth) | +| `crates/ferro_ta_core/Cargo.toml` | Same version for crates.io publish | +| `crates/ferro_ta_core/README.md` | Installation snippet should show the current crate version | +| `pyproject.toml` | Root | +| `wasm/package.json` | Package version | +| `conda/meta.yaml` | Conda recipe version | +| `docs/conf.py` | Default Sphinx release must resolve to the same version | + +**`Cargo.toml`** (root): +```toml +[package] +name = "ferro_ta" +version = "X.Y.Z" # ← or use scripts/bump_version.py X.Y.Z +``` + +**`pyproject.toml`**: +```toml +[project] +version = "X.Y.Z" # ← must match Cargo.toml exactly +``` + +> **Rule:** `Cargo.toml` is the source of truth. Sync the others to match before tagging. + +--- + +## Step 3 — Update CHANGELOG.md + +1. Open `CHANGELOG.md`. +2. Rename the `[Unreleased]` section to `[X.Y.Z] — YYYY-MM-DD` (today's date). +3. Add a fresh empty `[Unreleased]` section at the top. +4. Update the comparison links at the bottom: + +```markdown +[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/vX.Y.Z...HEAD +[X.Y.Z]: https://github.com/pratikbhadane24/ferro-ta/compare/vPREVIOUS...vX.Y.Z +``` + +Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format: +`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. + +Also update the docs-facing release surfaces for the same version: + +- `docs/changelog.rst` with a concise release-notes entry +- `docs/support_matrix.rst` if supported versions, tested wheels, or module + stability changed + +--- + +## Step 4 — Commit the version bump + +```bash +git add Cargo.toml crates/ferro_ta_core/Cargo.toml pyproject.toml wasm/package.json CHANGELOG.md +git commit -m "chore: release v0.2.0" +git push origin main +``` + +Wait for CI to pass on this commit before proceeding. + +--- + +## Step 5 — Create and push the tag + +```bash +git tag v0.2.0 +git push origin v0.2.0 +``` + +> Tags must be in the form `vMAJOR.MINOR.PATCH` (e.g. `v0.2.0`). + +--- + +## Step 6 — Create a GitHub Release + +1. Go to **Releases → Draft a new release** in the GitHub UI. +2. Select the tag `v0.2.0` you just pushed. +3. Set the release title to `v0.2.0`. +4. Paste the changelog section for `v0.2.0` into the release notes. +5. Click **Publish release**. + +Publishing the release triggers the CI wheel build jobs, `build-sdist`, and `publish` +automatically (the workflow responds to `release: published`). The PyPI upload +uses Trusted Publishing via GitHub OIDC, so no `PYPI_API_TOKEN` secret is used. + +--- + +## Step 7 — Monitor CI and verify PyPI + +1. Watch the **Actions** tab: the release wheel jobs, `build-sdist`, `publish` (PyPI), `publish-cratesio` (crates.io), and the **wasm-publish** workflow (npm). +2. After the `publish` job succeeds, verify the package is live: + +```bash +pip install ferro-ta==0.2.0 +python -c "import ferro_ta; print(ferro_ta.__version__ if hasattr(ferro_ta,'__version__') else 'ok')" +``` + +For version-specific verification, also check at least one install on each +supported Python line, for example: + +```bash +uv venv --python 3.13 .venv-313 +. .venv-313/bin/activate +uv pip install ferro-ta==0.2.0 +python -c "import ferro_ta; print(ferro_ta.SMA([1.0, 2.0, 3.0], 2))" +``` + +3. If anything fails: fix the issue, bump to a patch version (`0.2.1`), and repeat. + +--- + + +--- + +## Hotfix releases + +For urgent bug fixes on a released version: + +1. Branch from the release tag: `git checkout -b hotfix/0.1.1 v0.1.0` +2. Apply the fix, bump to `0.1.1`, update CHANGELOG. +3. Merge the branch into `main`. +4. Tag and release as above. + +--- + +> **Note:** `ferro_ta_core` is published to crates.io automatically by the CI job `publish-cratesio` when you publish a release (requires `CARGO_REGISTRY_TOKEN` secret). + +--- + +## See also + +- [VERSIONING.md](VERSIONING.md) — versioning policy and breaking-change rules +- [CHANGELOG.md](CHANGELOG.md) — changelog history +- [CONTRIBUTING.md](CONTRIBUTING.md) — development setup and PR guidelines diff --git a/ferro-ta-main/SECURITY.md b/ferro-ta-main/SECURITY.md new file mode 100644 index 0000000..92cf977 --- /dev/null +++ b/ferro-ta-main/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| latest | ✅ | + +## Reporting a Vulnerability + +If you discover a security vulnerability in ferro-ta, please **do not** open a +public GitHub issue. + +Instead, report it privately by emailing **pratikbhadane24@gmail.com** with: + +- A description of the vulnerability +- Steps to reproduce (or a minimal proof-of-concept) +- The potential impact + +You will receive a response within 7 days acknowledging receipt, and a +follow-up within 14 days with next steps. + +We will coordinate a fix and public disclosure together. We appreciate +responsible disclosure and will credit researchers who report issues in good +faith. + +## Scope + +ferro-ta is a numerical computation library. Security-relevant concerns include: + +- Memory safety issues in the Rust extension (buffer overflows, use-after-free, + etc.) +- Unsafe behaviour triggered by crafted input arrays +- Dependency vulnerabilities (tracked via `cargo audit` and Dependabot) + +Out of scope: issues in user code that calls ferro-ta, or theoretical attacks +that require direct file-system or network access. + +## Hardening and audits + +- **Fuzzing:** The project runs `cargo fuzz` targets (e.g. `fuzz_sma`, `fuzz_rsi`) in CI. Crashes are treated as failures; artifacts are uploaded for investigation. +- **Dependency audits:** CI runs `cargo audit` (Rust) and `pip-audit` (Python). Critical and high-severity vulnerabilities should be addressed before release. +- **Reporting:** If you have performed a security assessment or audit, we welcome a private summary to the contact above. diff --git a/ferro-ta-main/TA_LIB_COMPATIBILITY.md b/ferro-ta-main/TA_LIB_COMPATIBILITY.md new file mode 100644 index 0000000..52fea9c --- /dev/null +++ b/ferro-ta-main/TA_LIB_COMPATIBILITY.md @@ -0,0 +1,261 @@ +# TA-Lib Compatibility + +`ferro-ta` covers **100% of TA-Lib's function set** (`162+` indicators). This +file keeps the full GitHub-facing parity matrix in one place so the root +`README.md` can stay product-focused. + +See also: + +- [docs/migration_talib.rst](docs/migration_talib.rst) +- [docs/compatibility/talib.md](docs/compatibility/talib.md) +- [docs/support_matrix.rst](docs/support_matrix.rst) + +## Legend + + +| Symbol | Meaning | +| -------- | ------------------------------------------------------------------------------------------------------ | +| ✅ Exact | Values match TA-Lib to floating-point precision | +| ✅ Close | Values match after a short convergence window (EMA-seed difference) | +| ⚠️ Corr | Strong correlation (> 0.95) but not numerically identical (Wilder smoothing seed or algorithm variant) | +| ⚠️ Shape | Same output shape / NaN structure; values differ due to algorithm variant | +| ❌ | Not yet implemented | + + +## Overlap Studies + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ----------------------------------------------------- | +| `BBANDS` | ✅ | ✅ Exact | Bollinger Bands | +| `DEMA` | ✅ | ✅ Close | Double EMA; converges after ~20 bars | +| `EMA` | ✅ | ✅ Close | Exponential Moving Average; converges after ~20 bars | +| `KAMA` | ✅ | ✅ Exact | Kaufman Adaptive MA (values match after seed bar) | +| `MA` | ✅ | ✅ Exact | Moving average (generic, type-selectable) | +| `MAMA` | ✅ | ⚠️ Corr | MESA Adaptive MA | +| `MAVP` | ✅ | ✅ Exact | MA with variable period | +| `MIDPOINT` | ✅ | ✅ Exact | Midpoint over period | +| `MIDPRICE` | ✅ | ✅ Exact | Midpoint price over period | +| `SAR` | ✅ | ⚠️ Shape | Parabolic SAR (same shape; reversal history diverges) | +| `SAREXT` | ✅ | ⚠️ Shape | Parabolic SAR Extended | +| `SMA` | ✅ | ✅ Exact | Simple Moving Average | +| `T3` | ✅ | ✅ Close | Triple Exponential MA (T3); converges after ~50 bars | +| `TEMA` | ✅ | ✅ Close | Triple EMA; converges after ~20 bars | +| `TRIMA` | ✅ | ✅ Exact | Triangular Moving Average | +| `WMA` | ✅ | ✅ Exact | Weighted Moving Average | + + +## Momentum Indicators + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ---------------------------------------------------------------------------- | +| `ADX` | ✅ | ✅ Close | Avg Directional Movement Index (TA-Lib Wilder sum-seeding) | +| `ADXR` | ✅ | ✅ Close | ADX Rating (inherits ADX; TA-Lib seeding) | +| `APO` | ✅ | ✅ Close | Absolute Price Oscillator (EMA-based) | +| `AROON` | ✅ | ✅ Exact | Aroon Up/Down | +| `AROONOSC` | ✅ | ✅ Exact | Aroon Oscillator | +| `BOP` | ✅ | ✅ Exact | Balance Of Power | +| `CCI` | ✅ | ✅ Exact | Commodity Channel Index (TA-Lib-compatible MAD formula) | +| `CMO` | ✅ | ✅ Close | Chande Momentum Oscillator (rolling window, TA-Lib-compatible) | +| `DX` | ✅ | ✅ Close | Directional Movement Index (TA-Lib Wilder sum-seeding) | +| `MACD` | ✅ | ✅ Close | MACD (EMA-based; converges after ~30 bars) | +| `MACDEXT` | ✅ | ✅ Close | MACD with controllable MA type (EMA-based; converges) | +| `MACDFIX` | ✅ | ✅ Close | MACD Fixed 12/26 (EMA-based; converges) | +| `MFI` | ✅ | ✅ Exact | Money Flow Index | +| `MINUS_DI` | ✅ | ✅ Close | Minus Directional Indicator (TA-Lib Wilder sum-seeding) | +| `MINUS_DM` | ✅ | ✅ Close | Minus Directional Movement (TA-Lib Wilder sum-seeding) | +| `MOM` | ✅ | ✅ Exact | Momentum | +| `PLUS_DI` | ✅ | ✅ Close | Plus Directional Indicator (TA-Lib Wilder sum-seeding) | +| `PLUS_DM` | ✅ | ✅ Close | Plus Directional Movement (TA-Lib Wilder sum-seeding) | +| `PPO` | ✅ | ✅ Close | Percentage Price Oscillator (EMA-based) | +| `ROC` | ✅ | ✅ Exact | Rate of Change | +| `ROCP` | ✅ | ✅ Exact | Rate of Change Percentage | +| `ROCR` | ✅ | ✅ Exact | Rate of Change Ratio | +| `ROCR100` | ✅ | ✅ Exact | Rate of Change Ratio × 100 | +| `RSI` | ✅ | ✅ Close | Relative Strength Index (TA-Lib Wilder seeding; converges after ~1 seed bar) | +| `STOCH` | ✅ | ✅ Close | Stochastic (TA-Lib-compatible SMA smoothing for slowk and slowd) | +| `STOCHF` | ✅ | ✅ Exact | Stochastic Fast (%K exact; %D NaN offset ±2) | +| `STOCHRSI` | ✅ | ✅ Close | Stochastic RSI (TA-Lib-compatible; SMA fastd, Wilder-seeded RSI) | +| `TRIX` | ✅ | ✅ Close | 1-day ROC of Triple EMA (EMA-based; converges) | +| `ULTOSC` | ✅ | ✅ Exact | Ultimate Oscillator | +| `WILLR` | ✅ | ✅ Exact | Williams' %R | + + +## Volume Indicators + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ------------------------------------------------------------------ | +| `AD` | ✅ | ✅ Exact | Chaikin A/D Line | +| `ADOSC` | ✅ | ✅ Exact | Chaikin A/D Oscillator | +| `OBV` | ✅ | ✅ Exact | On Balance Volume (increments identical; constant offset at bar 0) | + + +## Volatility Indicators + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ----------------------------------------------------------------------- | +| `ATR` | ✅ | ✅ Close | Average True Range (TA-Lib Wilder seeding; matches from bar timeperiod) | +| `NATR` | ✅ | ✅ Close | Normalized ATR (TA-Lib Wilder seeding) | +| `TRANGE` | ✅ | ✅ Exact | True Range (bar 0 differs; all others identical) | + + +## Cycle Indicators + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ---------------------------------------------------------- | +| `HT_DCPERIOD` | ✅ | ⚠️ Shape | Hilbert Transform Dominant Cycle Period (Ehlers algorithm) | +| `HT_DCPHASE` | ✅ | ⚠️ Shape | Hilbert Transform Dominant Cycle Phase | +| `HT_PHASOR` | ✅ | ⚠️ Shape | Hilbert Transform Phasor Components (inphase, quadrature) | +| `HT_SINE` | ✅ | ⚠️ Shape | Hilbert Transform SineWave (sine, leadsine) | +| `HT_TRENDLINE` | ✅ | ⚠️ Shape | Hilbert Transform Instantaneous Trendline | +| `HT_TRENDMODE` | ✅ | ⚠️ Shape | Hilbert Transform Trend vs Cycle Mode (1=trend, 0=cycle) | + + +## Price Transformations + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | -------------------- | +| `AVGPRICE` | ✅ | ✅ Exact | Average Price | +| `MEDPRICE` | ✅ | ✅ Exact | Median Price | +| `TYPPRICE` | ✅ | ✅ Exact | Typical Price | +| `WCLPRICE` | ✅ | ✅ Exact | Weighted Close Price | + + +## Statistic Functions + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------------- | -------- | -------- | ----------------------------------------------------------- | +| `BETA` | ✅ | ✅ Close | Beta coefficient (returns-based regression matching TA-Lib) | +| `CORREL` | ✅ | ✅ Exact | Pearson Correlation Coefficient | +| `LINEARREG` | ✅ | ✅ Exact | Linear Regression | +| `LINEARREG_ANGLE` | ✅ | ✅ Exact | Linear Regression Angle | +| `LINEARREG_INTERCEPT` | ✅ | ✅ Exact | Linear Regression Intercept | +| `LINEARREG_SLOPE` | ✅ | ✅ Exact | Linear Regression Slope | +| `STDDEV` | ✅ | ✅ Exact | Standard Deviation | +| `TSF` | ✅ | ✅ Exact | Time Series Forecast | +| `VAR` | ✅ | ✅ Exact | Variance | + + +## Pattern Recognition + +`ferro-ta` implements all 61 candlestick patterns. All return the same +`{-100, 0, 100}` convention as TA-Lib. Pattern thresholds may differ slightly +from the full TA-Lib implementation. + + +| TA-Lib Function | ferro-ta | Notes | +| --------------------- | -------- | --------------------------------------------------- | +| `CDL2CROWS` | ✅ | Two Crows | +| `CDL3BLACKCROWS` | ✅ | Three Black Crows | +| `CDL3INSIDE` | ✅ | Three Inside Up/Down | +| `CDL3LINESTRIKE` | ✅ | Three-Line Strike | +| `CDL3OUTSIDE` | ✅ | Three Outside Up/Down | +| `CDL3STARSINSOUTH` | ✅ | Three Stars In The South | +| `CDL3WHITESOLDIERS` | ✅ | Three Advancing White Soldiers | +| `CDLABANDONEDBABY` | ✅ | Abandoned Baby | +| `CDLADVANCEBLOCK` | ✅ | Advance Block | +| `CDLBELTHOLD` | ✅ | Belt-hold | +| `CDLBREAKAWAY` | ✅ | Breakaway | +| `CDLCLOSINGMARUBOZU` | ✅ | Closing Marubozu | +| `CDLCONCEALBABYSWALL` | ✅ | Concealing Baby Swallow | +| `CDLCOUNTERATTACK` | ✅ | Counterattack | +| `CDLDARKCLOUDCOVER` | ✅ | Dark Cloud Cover | +| `CDLDOJI` | ✅ | Doji | +| `CDLDOJISTAR` | ✅ | Doji Star | +| `CDLDRAGONFLYDOJI` | ✅ | Dragonfly Doji | +| `CDLENGULFING` | ✅ | Engulfing Pattern | +| `CDLEVENINGDOJISTAR` | ✅ | Evening Doji Star | +| `CDLEVENINGSTAR` | ✅ | Evening Star | +| `CDLGAPSIDESIDEWHITE` | ✅ | Up/Down-gap side-by-side white lines | +| `CDLGRAVESTONEDOJI` | ✅ | Gravestone Doji | +| `CDLHAMMER` | ✅ | Hammer | +| `CDLHANGINGMAN` | ✅ | Hanging Man | +| `CDLHARAMI` | ✅ | Harami Pattern | +| `CDLHARAMICROSS` | ✅ | Harami Cross Pattern | +| `CDLHIGHWAVE` | ✅ | High-Wave Candle | +| `CDLHIKKAKE` | ✅ | Hikkake Pattern | +| `CDLHIKKAKEMOD` | ✅ | Modified Hikkake Pattern | +| `CDLHOMINGPIGEON` | ✅ | Homing Pigeon | +| `CDLIDENTICAL3CROWS` | ✅ | Identical Three Crows | +| `CDLINNECK` | ✅ | In-Neck Pattern | +| `CDLINVERTEDHAMMER` | ✅ | Inverted Hammer | +| `CDLKICKING` | ✅ | Kicking | +| `CDLKICKINGBYLENGTH` | ✅ | Kicking by the longer Marubozu | +| `CDLLADDERBOTTOM` | ✅ | Ladder Bottom | +| `CDLLONGLEGGEDDOJI` | ✅ | Long Legged Doji | +| `CDLLONGLINE` | ✅ | Long Line Candle | +| `CDLMARUBOZU` | ✅ | Marubozu | +| `CDLMATCHINGLOW` | ✅ | Matching Low | +| `CDLMATHOLD` | ✅ | Mat Hold | +| `CDLMORNINGDOJISTAR` | ✅ | Morning Doji Star | +| `CDLMORNINGSTAR` | ✅ | Morning Star | +| `CDLONNECK` | ✅ | On-Neck Pattern | +| `CDLPIERCING` | ✅ | Piercing Pattern | +| `CDLRICKSHAWMAN` | ✅ | Rickshaw Man | +| `CDLRISEFALL3METHODS` | ✅ | Rising/Falling Three Methods | +| `CDLSEPARATINGLINES` | ✅ | Separating Lines | +| `CDLSHOOTINGSTAR` | ✅ | Shooting Star | +| `CDLSHORTLINE` | ✅ | Short Line Candle | +| `CDLSPINNINGTOP` | ✅ | Spinning Top | +| `CDLSTALLEDPATTERN` | ✅ | Stalled Pattern | +| `CDLSTICKSANDWICH` | ✅ | Stick Sandwich | +| `CDLTAKURI` | ✅ | Takuri (Dragonfly Doji with very long lower shadow) | +| `CDLTASUKIGAP` | ✅ | Tasuki Gap | +| `CDLTHRUSTING` | ✅ | Thrusting Pattern | +| `CDLTRISTAR` | ✅ | Tristar Pattern | +| `CDLUNIQUE3RIVER` | ✅ | Unique 3 River | +| `CDLUPSIDEGAP2CROWS` | ✅ | Upside Gap Two Crows | +| `CDLXSIDEGAP3METHODS` | ✅ | Upside/Downside Gap Three Methods | + + +## Math Operators / Math Transforms + +`ferro-ta` provides TA-Lib-compatible wrappers for all arithmetic and +math-transform functions. Rolling functions (`SUM`, `MAX`, `MIN`) produce `NaN` +for the first `timeperiod - 1` bars. + + +| TA-Lib Function | ferro-ta | Notes | +| ------------------------ | -------- | ----------------------------- | +| `ADD` | ✅ | Element-wise addition | +| `SUB` | ✅ | Element-wise subtraction | +| `MULT` | ✅ | Element-wise multiplication | +| `DIV` | ✅ | Element-wise division | +| `SUM` | ✅ | Rolling sum over *timeperiod* | +| `MAX` / `MAXINDEX` | ✅ | Rolling maximum / index | +| `MIN` / `MININDEX` | ✅ | Rolling minimum / index | +| `ACOS` / `ASIN` / `ATAN` | ✅ | Arc trig transforms | +| `CEIL` / `FLOOR` | ✅ | Round up / down | +| `COS` / `SIN` / `TAN` | ✅ | Trig transforms | +| `COSH` / `SINH` / `TANH` | ✅ | Hyperbolic transforms | +| `EXP` / `LN` / `LOG10` | ✅ | Exponential / log transforms | +| `SQRT` | ✅ | Square root | + + +## Implementation Coverage Summary + + +| Category | Implemented | Not Implemented | +| --------------------------- | ----------- | --------------- | +| Overlap Studies | 19 | 0 | +| Momentum Indicators | 28 | 0 | +| Volume Indicators | 3 | 0 | +| Volatility Indicators | 3 | 0 | +| Cycle Indicators | 6 | 0 | +| Price Transforms | 4 | 0 | +| Statistic Functions | 9 | 0 | +| Pattern Recognition | 61 | 0 | +| Math Operators / Transforms | 24 | 0 | +| Extended Indicators | 10 | - | +| Streaming Classes | 9 | - | +| **Total** | **162+** | **0** | + + +> `ferro-ta` implements 100% of TA-Lib's function set. NaN values are placed +> at the beginning of each output array for the warmup period. diff --git a/ferro-ta-main/TROUBLESHOOTING.md b/ferro-ta-main/TROUBLESHOOTING.md new file mode 100644 index 0000000..aea8418 --- /dev/null +++ b/ferro-ta-main/TROUBLESHOOTING.md @@ -0,0 +1,186 @@ +# ferro-ta Troubleshooting Guide + +Common build and runtime issues and how to fix them. + +--- + +## Table of Contents + +1. [maturin build fails](#maturin-build-fails) +2. [PyO3 version mismatches](#pyo3-version-mismatches) +3. [ImportError: cannot import name '_ferro_ta'](#importerror-cannot-import-name-_ferro_ta) +4. [Rust toolchain not found](#rust-toolchain-not-found) +5. [tests fail with 'ferro_ta not installed'](#tests-fail-with-ferro_ta-not-installed) +6. [mypy / pyright type errors after install](#mypy--pyright-type-errors-after-install) +7. [WASM build fails](#wasm-build-fails) +8. [GPU / CuPy errors](#gpu--cupy-errors) +9. [Coverage below threshold](#coverage-below-threshold) +10. [Common Rust compilation errors](#common-rust-compilation-errors) + +--- + +## maturin build fails + +**Symptom:** `maturin develop` or `maturin build` exits with a Rust compilation error. + +**Fixes:** +- Ensure you have the **stable** Rust toolchain installed: + ```bash + rustup toolchain install stable + rustup default stable + ``` +- Ensure `rustfmt` and `clippy` components are installed: + ```bash + rustup component add rustfmt clippy + ``` +- Make sure Python headers are available. On Debian/Ubuntu: + ```bash + sudo apt-get install python3-dev + ``` +- If you changed `Cargo.toml`, run `cargo check` first to isolate Rust errors from maturin wrapping issues. + +--- + +## PyO3 version mismatches + +**Symptom:** `pyo3` version conflict between your Python interpreter and the version pinned in `Cargo.toml`. + +**Fix:** ferro-ta uses PyO3 with the `abi3` feature flag which supports Python 3.10+. If you need a specific version: +```toml +# Cargo.toml +[dependencies] +pyo3 = { version = "0.22", features = ["extension-module", "abi3-py310"] } +``` +Run `cargo update -p pyo3` to pull the latest compatible version. + +--- + +## ImportError: cannot import name '_ferro_ta' + +**Symptom:** +``` +ImportError: cannot import name '_ferro_ta' from 'ferro_ta' +``` + +**Causes and fixes:** +1. The Rust extension has not been compiled yet — run `maturin develop --release` or `make build`. +2. The `.so` file was compiled for a different Python version — rebuild with the current interpreter. +3. You are running `python` from a different virtualenv — activate the correct environment. + +Check that the compiled extension is present: +```bash +python -c "import ferro_ta._ferro_ta; print('OK')" +``` + +--- + +## Rust toolchain not found + +**Symptom:** `cargo: command not found` or `rustup: command not found`. + +**Fix:** +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source "$HOME/.cargo/env" +``` + +--- + +## tests fail with 'ferro_ta not installed' + +**Symptom:** pytest reports import errors for `ferro_ta`. + +**Fix:** Build and install the development wheel first: +```bash +maturin develop --release +# or +make build +``` +Then re-run tests: +```bash +pytest tests/ +``` + +--- + +## mypy / pyright type errors after install + +**Symptom:** mypy or pyright reports errors for optional dependencies (cupy, polars, etc.). + +**Fix:** ferro-ta ships a `pyrightconfig.json` that sets `reportMissingImports = false` for optional deps. For mypy, pass `--ignore-missing-imports`: +```bash +mypy python/ferro_ta --ignore-missing-imports +``` +The CI uses this flag by default. + +--- + +## WASM build fails + +**Symptom:** `wasm-pack build` fails inside `wasm/`. + +**Fix:** +1. Install `wasm-pack`: + ```bash + curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + ``` +2. Add the WASM target: + ```bash + rustup target add wasm32-unknown-unknown + ``` +3. Run from the `wasm/` subdirectory (it has its own `[workspace]` table): + ```bash + cd wasm && wasm-pack build --target nodejs + ``` + +--- + +## GPU / CuPy errors + +**Symptom:** `ImportError: No module named 'cupy'` or CUDA errors in `ferro_ta.gpu`. + +**Fix:** The GPU module is **optional**. Install CuPy matching your CUDA version: +```bash +pip install cupy-cuda12x # for CUDA 12.x +``` +If no GPU is available, all ferro_ta functions fall back silently to CPU (NumPy) computation. + +--- + +## Coverage below threshold + +**Symptom:** `pytest --cov-fail-under=65` fails with a coverage percentage below 65 %. + +**Fix:** +- Run `pytest tests/ --cov=ferro_ta --cov-report=term-missing` to see which lines are uncovered. +- The threshold in CI is 65 %. Local runs may vary if optional dependencies (pandas, polars) are not installed. +- Install all test extras: + ```bash + pip install pandas polars hypothesis pyyaml + ``` + +--- + +## Common Rust compilation errors + +### `error[E0425]: cannot find function 'compute_ema'` + +Dead code was removed in a refactor. Run `cargo clean` then `cargo build --release`. + +### `error: current package believes it's in a workspace when it's not` + +This happens in `fuzz/` or `wasm/` sub-crates. Both have `[workspace]` in their `Cargo.toml` to opt out of the root workspace. If you create a new sub-crate, add `[workspace]` to its `Cargo.toml`. + +### Linker errors on macOS (Apple Silicon) + +```bash +export MACOSX_DEPLOYMENT_TARGET=11.0 +maturin develop --release +``` + +--- + +## Getting help + +- Open an issue: +- See `CONTRIBUTING.md` for development guidelines. diff --git a/ferro-ta-main/VERSIONING.md b/ferro-ta-main/VERSIONING.md new file mode 100644 index 0000000..2568dc2 --- /dev/null +++ b/ferro-ta-main/VERSIONING.md @@ -0,0 +1,124 @@ +# Versioning & Release Policy + +**ferro-ta** uses [Semantic Versioning 2.0.0](https://semver.org/). + +## Version numbers: `MAJOR.MINOR.PATCH` + +| Component | Increment when… | +|-----------|-----------------| +| **MAJOR** | A breaking API change is introduced (indicator removed, parameter renamed, return-type changed). | +| **MINOR** | New indicators, features, or bindings are added in a backward-compatible way. | +| **PATCH** | Bug fixes, performance improvements, documentation-only changes, or dependency bumps that do not change the public API. | + +## Supported Python versions + +We support the **three most recent stable Python minor releases** at the time +of a MINOR or MAJOR release. Python versions that have reached end-of-life +(EOL) per the [Python release calendar](https://devguide.python.org/versions/) +are removed in the next MINOR release; this counts as a non-breaking change. + +Currently supported: **3.10, 3.11, 3.12, 3.13** (see `pyproject.toml`). + +## Release playbook + +### Fast path + +1. **Bump tracked version files with one command**: + ```bash + python3 scripts/bump_version.py 1.0.3 + ``` + or: + ```bash + make version VERSION=1.0.3 + ``` +2. **Verify everything matches**: + ```bash + python3 scripts/bump_version.py --check + ``` +3. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section + `[1.0.1] — YYYY-MM-DD` and open a fresh `[Unreleased]` block. +4. **Commit** the version bump and changelog update with message + `chore: release v1.0.1`. +5. **Create a tag**: `git tag v1.0.1 && git push origin v1.0.1`. +6. **Create a GitHub Release** for tag `v1.0.1` — the CI `build-wheels` and + `publish` jobs trigger automatically on `release: published`. + +The bump script updates the tracked release-version carriers that are easy to +miss manually: root Cargo, Python packaging, the core crate, the core crate +README install snippet, the WASM package, the Conda recipe, and the docs pages +that show the current released version. + +## Breaking-change policy + +- Removing an indicator or changing its signature is a **MAJOR** change. +- Changing a default parameter value is a **MINOR** change (with a deprecation + notice in the changelog). +- Fixing a numeric output to match TA-Lib more closely is a **PATCH** change + (but noted clearly in the changelog). + +## Changelog maintenance + +Every PR that changes user-visible behaviour must add an entry to the +`[Unreleased]` section of `CHANGELOG.md`. CI enforces this for PRs that +touch `src/`, `python/`, or `wasm/`. + +--- + +## API Stability Guarantees + +The following modules are considered **stable API** as of `1.0.0` and will not +have breaking changes in minor releases: + +| Module | Stability | +|---|---| +| `ferro_ta` (top-level) — all `__all__` names | Stable | +| `ferro_ta.overlap`, `ferro_ta.momentum`, etc. | Stable | +| `ferro_ta.batch` | Stable | +| `ferro_ta.streaming` | Stable | +| `ferro_ta.extended` | Stable | +| `ferro_ta.exceptions` | Stable | +| `ferro_ta.registry` | Stable | +| `ferro_ta.backtest` | Stable | +| `ferro_ta.pipeline` | Stable | +| `ferro_ta.config` | Stable | +| `ferro_ta.gpu` (optional) | Beta — API may evolve | +| `ferro_ta._utils` (private) | Not stable — do not import directly | + +### v1.0 readiness checklist + +- [x] All 155+ TA-Lib indicators implemented and tested +- [x] Type stubs (`.pyi`) for all public functions +- [x] Sphinx documentation for all modules +- [x] Streaming bar-by-bar API (9 classes) +- [x] Batch execution API +- [x] Extended indicators (10 beyond TA-Lib) +- [x] WASM bindings (9 indicators) +- [x] Pandas integration (transparent) +- [x] Polars integration (transparent) +- [x] Backtesting harness +- [x] Plugin registry +- [x] Error model and input validation +- [x] Release playbook (RELEASE.md) +- [x] Changelog (CHANGELOG.md) +- [x] Version consistency CI check +- [x] Fuzzing (cargo-fuzz, SMA + RSI) +- [x] Optional GPU backend (CuPy) +- [x] Indicator pipeline API +- [x] Configuration defaults API +- [x] Jupyter notebook examples + +### Post-1.0 notes + +With `1.0.0` released: +1. The package now uses the `Development Status :: 5 - Production/Stable` classifier. +2. `CHANGELOG.md` now contains the `[1.0.0]` release section. +3. This file now reflects the stable-series SemVer contract. + +### Compatibility matrix + +| Python | Platform | Status | +|---|---|---| +| 3.10–3.13 | Linux x86_64 (manylinux) | ✅ Supported | +| 3.10–3.13 | macOS x86_64 | ✅ Supported | +| 3.10–3.13 | macOS aarch64 (Apple Silicon) | ✅ Supported | +| 3.10–3.13 | Windows x86_64 | ✅ Supported | diff --git a/ferro-ta-main/api/Dockerfile b/ferro-ta-main/api/Dockerfile new file mode 100644 index 0000000..5443bb9 --- /dev/null +++ b/ferro-ta-main/api/Dockerfile @@ -0,0 +1,50 @@ +# ferro-ta API — Docker image +# +# Build: +# docker build -t ferro-ta-api . +# +# Run: +# docker run -p 8000:8000 ferro-ta-api +# +# Environment variables (override at runtime): +# MAX_SERIES_LENGTH=100000 # maximum data-point count per request +# +# CPU portability +# --------------- +# This image installs the PRE-BUILT ferro-ta wheel from PyPI — we do NOT +# recompile from sdist with `RUSTFLAGS=-C target-cpu=...`. The wheel is built +# at the manylinux baseline (x86-64-v1) and selects AVX2/AVX-512/NEON kernels +# at RUNTIME via CPU dispatch. One image therefore runs on any node — old or +# new CPU, x86_64 or arm64 — with no illegal-instruction (SIGILL) crashes. +# Pinning a target-cpu would be faster on a uniform fleet but would crash on +# any older/heterogeneous node, which is the opposite of broad coverage. +# +# Build this image for whichever arch your nodes use: +# docker build --platform linux/amd64 -t ferro-ta-api . +# docker build --platform linux/arm64 -t ferro-ta-api . # Graviton/Ampere +# Both resolve a matching manylinux wheel — no Rust toolchain needed here. + +FROM python:3.11-slim + +WORKDIR /app + +# Copy and install dependencies first (cache layer). No compiler is needed: +# ferro-ta, numpy, and pydantic-core all ship prebuilt wheels for linux +# x86_64 and aarch64. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Fail the build immediately if the wheel did not resolve for this arch +# (e.g. an exotic platform that fell back to an sdist build without Rust). +RUN python -c "import ferro_ta, numpy as np; ferro_ta.SMA(np.arange(10.0), 3); print('ferro_ta', ferro_ta.__version__, 'import OK')" + +# Copy API source +COPY main.py ./ + +# Expose API port +EXPOSE 8000 + +ENV MAX_SERIES_LENGTH=100000 + +# Run with uvicorn (single worker; scale horizontally via Docker Compose / k8s) +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/ferro-ta-main/api/main.py b/ferro-ta-main/api/main.py new file mode 100644 index 0000000..70efbb7 --- /dev/null +++ b/ferro-ta-main/api/main.py @@ -0,0 +1,305 @@ +""" +ferro-ta REST API +============================ + +A minimal FastAPI service that exposes ferro-ta indicators and backtest +over HTTP so that any client can compute technical analysis via REST. + +Endpoints +--------- +GET /health — readiness / liveness probe +POST /indicators/sma — Simple Moving Average +POST /indicators/ema — Exponential Moving Average +POST /indicators/rsi — Relative Strength Index +POST /indicators/macd — MACD (line, signal, histogram) +POST /indicators/bbands — Bollinger Bands +POST /backtest — Vectorized backtest + +Request / Response format +------------------------- +All indicator endpoints accept JSON: + { + "close": [1.0, 2.0, ...], // required; array of floats + "timeperiod": 14 // optional parameter + } + +And return: + { + "result": [null, null, ..., 14.0, ...] // null for NaN warm-up + } + +Or for multi-output indicators (MACD, BBANDS): + { + "result": { + "macd": [...], + "signal": [...], + "hist": [...] + } + } + +For the backtest endpoint the request is: + { + "close": [1.0, 2.0, ...], + "strategy": "rsi_30_70", // or "sma_crossover", "macd_crossover" + "commission_per_trade": 0.0, + "slippage_bps": 0.0 + } + +And the response is: + { + "final_equity": 1.123, + "n_trades": 7, + "equity": [1.0, ...] + } + +Running +------- +Development:: + + uvicorn api.main:app --reload --port 8000 + +Production:: + + uvicorn api.main:app --host 0.0.0.0 --port 8000 --workers 4 + +Docker:: + + docker build -t ferro-ta-api ./api + docker run -p 8000:8000 ferro-ta-api + +Environment variables +--------------------- +MAX_SERIES_LENGTH : int — maximum number of data points per request + (default 100 000). Requests exceeding this limit return HTTP 413. +""" + +from __future__ import annotations + +import math +import os +from typing import Any + +import numpy as np + +try: + from fastapi import FastAPI, HTTPException + from pydantic import BaseModel, Field, field_validator +except ImportError as exc: # pragma: no cover + raise ImportError( + "The ferro-ta API requires fastapi and pydantic.\n" + "Install with: pip install 'ferro_ta[api]'" + ) from exc + +import ferro_ta as ft +from ferro_ta.analysis.backtest import backtest as _backtest + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +MAX_SERIES_LENGTH = int(os.environ.get("MAX_SERIES_LENGTH", "100000")) + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastAPI( + title="ferro-ta API", + description="REST API for ferro-ta technical analysis indicators and backtesting.", + version=ft.__version__, + docs_url="/docs", + redoc_url="/redoc", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _nan_to_none(arr: np.ndarray) -> list[float | None]: + """Convert numpy array to list, replacing NaN/Inf with None.""" + return [None if not math.isfinite(v) else float(v) for v in arr] + + +def _validate_series(close: list[float]) -> np.ndarray: + if len(close) > MAX_SERIES_LENGTH: + raise HTTPException( + status_code=413, + detail=f"Series length {len(close)} exceeds maximum {MAX_SERIES_LENGTH}.", + ) + if len(close) < 2: + raise HTTPException( + status_code=422, + detail="Series must contain at least 2 values.", + ) + return np.asarray(close, dtype=np.float64) + + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + + +class IndicatorRequest(BaseModel): + close: list[float] = Field(..., description="Close price series") + timeperiod: int = Field(default=14, ge=1, description="Look-back period") + + @field_validator("close") + @classmethod + def close_must_be_finite(cls, v: list[float]) -> list[float]: + if not all(math.isfinite(x) for x in v): + raise ValueError("close series must contain only finite values") + return v + + +class MACDRequest(BaseModel): + close: list[float] = Field(..., description="Close price series") + fastperiod: int = Field(default=12, ge=1) + slowperiod: int = Field(default=26, ge=1) + signalperiod: int = Field(default=9, ge=1) + + @field_validator("close") + @classmethod + def close_must_be_finite(cls, v: list[float]) -> list[float]: + if not all(math.isfinite(x) for x in v): + raise ValueError("close series must contain only finite values") + return v + + +class BBANDSRequest(BaseModel): + close: list[float] = Field(..., description="Close price series") + timeperiod: int = Field(default=5, ge=2) + nbdevup: float = Field(default=2.0, gt=0) + nbdevdn: float = Field(default=2.0, gt=0) + + @field_validator("close") + @classmethod + def close_must_be_finite(cls, v: list[float]) -> list[float]: + if not all(math.isfinite(x) for x in v): + raise ValueError("close series must contain only finite values") + return v + + +class BacktestRequest(BaseModel): + close: list[float] = Field(..., description="Close price series") + strategy: str = Field(default="rsi_30_70") + commission_per_trade: float = Field(default=0.0, ge=0.0) + slippage_bps: float = Field(default=0.0, ge=0.0) + + @field_validator("close") + @classmethod + def close_must_be_finite(cls, v: list[float]) -> list[float]: + if not all(math.isfinite(x) for x in v): + raise ValueError("close series must contain only finite values") + return v + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@app.get("/health", summary="Health check") +def health() -> dict[str, str]: + """Readiness / liveness probe.""" + return {"status": "ok", "version": app.version} + + +@app.post("/indicators/sma", summary="Simple Moving Average") +def compute_sma(req: IndicatorRequest) -> dict[str, Any]: + """Compute Simple Moving Average (SMA). + + Returns ``result``: list of floats (null for warm-up bars). + """ + c = _validate_series(req.close) + out = np.asarray(ft.SMA(c, timeperiod=req.timeperiod), dtype=np.float64) + return {"result": _nan_to_none(out)} + + +@app.post("/indicators/ema", summary="Exponential Moving Average") +def compute_ema(req: IndicatorRequest) -> dict[str, Any]: + """Compute Exponential Moving Average (EMA).""" + c = _validate_series(req.close) + out = np.asarray(ft.EMA(c, timeperiod=req.timeperiod), dtype=np.float64) + return {"result": _nan_to_none(out)} + + +@app.post("/indicators/rsi", summary="Relative Strength Index") +def compute_rsi(req: IndicatorRequest) -> dict[str, Any]: + """Compute Relative Strength Index (RSI).""" + c = _validate_series(req.close) + out = np.asarray(ft.RSI(c, timeperiod=req.timeperiod), dtype=np.float64) + return {"result": _nan_to_none(out)} + + +@app.post("/indicators/macd", summary="MACD") +def compute_macd(req: MACDRequest) -> dict[str, Any]: + """Compute MACD (line, signal, histogram). + + Returns ``result`` with keys ``macd``, ``signal``, ``hist``. + """ + c = _validate_series(req.close) + macd, signal, hist = ft.MACD( + c, + fastperiod=req.fastperiod, + slowperiod=req.slowperiod, + signalperiod=req.signalperiod, + ) + return { + "result": { + "macd": _nan_to_none(np.asarray(macd, dtype=np.float64)), + "signal": _nan_to_none(np.asarray(signal, dtype=np.float64)), + "hist": _nan_to_none(np.asarray(hist, dtype=np.float64)), + } + } + + +@app.post("/indicators/bbands", summary="Bollinger Bands") +def compute_bbands(req: BBANDSRequest) -> dict[str, Any]: + """Compute Bollinger Bands (upper, middle, lower). + + Returns ``result`` with keys ``upper``, ``middle``, ``lower``. + """ + c = _validate_series(req.close) + upper, middle, lower = ft.BBANDS( + c, + timeperiod=req.timeperiod, + nbdevup=req.nbdevup, + nbdevdn=req.nbdevdn, + ) + return { + "result": { + "upper": _nan_to_none(np.asarray(upper, dtype=np.float64)), + "middle": _nan_to_none(np.asarray(middle, dtype=np.float64)), + "lower": _nan_to_none(np.asarray(lower, dtype=np.float64)), + } + } + + +@app.post("/backtest", summary="Vectorized backtest") +def run_backtest(req: BacktestRequest) -> dict[str, Any]: + """Run a vectorized backtest using a named strategy. + + Strategies: ``rsi_30_70``, ``sma_crossover``, ``macd_crossover``. + Returns ``final_equity``, ``n_trades``, and the full ``equity`` curve. + """ + c = _validate_series(req.close) + valid_strategies = {"rsi_30_70", "sma_crossover", "macd_crossover"} + if req.strategy not in valid_strategies: + raise HTTPException( + status_code=422, + detail=f"Unknown strategy '{req.strategy}'. " + f"Available: {sorted(valid_strategies)}", + ) + result = _backtest( + c, + strategy=req.strategy, + commission_per_trade=req.commission_per_trade, + slippage_bps=req.slippage_bps, + ) + return { + "final_equity": float(result.final_equity), + "n_trades": int(result.n_trades), + "equity": _nan_to_none(result.equity), + } diff --git a/ferro-ta-main/api/requirements.txt b/ferro-ta-main/api/requirements.txt new file mode 100644 index 0000000..bb102cd --- /dev/null +++ b/ferro-ta-main/api/requirements.txt @@ -0,0 +1,6 @@ +# Runtime dependencies for ferro-ta API +ferro_ta>=1.1.4 +fastapi>=0.110.0 +uvicorn[standard]>=0.49.0 +pydantic>=2.13.4 +numpy>=1.20 diff --git a/ferro-ta-main/benchmarks/README.md b/ferro-ta-main/benchmarks/README.md new file mode 100644 index 0000000..eb890d7 --- /dev/null +++ b/ferro-ta-main/benchmarks/README.md @@ -0,0 +1,321 @@ +# ferro-ta Benchmark Suite + +> Reproducible speed and accuracy comparisons across 62 indicators and the +> libraries available in your environment. + +## Overview + +The benchmark suite compares **ferro-ta** against other Python +technical-analysis libraries on a common dataset and shared wrappers so the +results are easier to reproduce and critique. + +It is not designed to prove that ferro-ta wins everywhere. It is designed to +show where ferro-ta is faster, where it only ties, and where another library +still wins. + +| Library | Notes | +|-----------|-------| +| **TA-Lib** | C extension; widely used comparison baseline | +| **pandas-ta** | Pure Python; broad indicator set | +| **ta** | Simple API; some indicators use O(n²) loops and are very slow | +| **Tulipy** | C extension; truncated output (no leading NaN padding) | +| **finta** | Expects DatetimeIndex DataFrame; some indicators very slow | + +--- + +## Dataset (LARGE = 100k bars) + +All **speed benchmarks** use the **LARGE** dataset: **100,000 bars** of OHLCV data. + +- **Source:** `benchmarks/data_generator.py` — geometric Brownian motion for realistic prices; C-contiguous `float64` arrays for all libraries. +- **Why 100k:** Reflects backtesting and batch workloads; stresses memory and CPU so differences between libraries are clear. +- **Scales available:** `SMALL` (1k), `MEDIUM` (10k), `LARGE` (100k). Speed suite uses **LARGE** by default. + +```python +from benchmarks.data_generator import SMALL, MEDIUM, LARGE +# SMALL = 1,000 bars +# MEDIUM = 10,000 bars (e.g. accuracy tests) +# LARGE = 100,000 bars (speed benchmarks) +``` + +--- + +## Methodology + +- **Harness:** [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) with `benchmark.pedantic(..., iterations=5, rounds=20, warmup_rounds=2)`. +- **Reported metric:** **Median time per call** in **microseconds (µs)** — lower is better. +- **TA-Lib head-to-head JSON:** `benchmarks/bench_vs_talib.py` records per-run samples, variance stats, machine/runtime/build metadata, and Python-tracked peak allocation snapshots. +- **Machine info:** Stored in the generated JSON artifacts for reproducibility. +- **Libraries:** Only libraries present in the environment are benchmarked; missing ones are skipped. + +## Current checked-in TA-Lib artifact + +The checked-in `benchmarks/artifacts/latest/benchmark_vs_talib.json` artifact +uses contiguous `float64` arrays at 10k and 100k bars on an Apple M3 Max, +CPython 3.13.5, and Rust 1.91.1 with the default release profile +(`lto = true`, `codegen-units = 1`). + +- ferro-ta is ahead outside the tie band on 6 of 12 rows at 10k bars and 6 of 12 rows at 100k bars. +- TA-Lib still wins in the current artifact on `STOCH` and `ADX`, and remains close on `EMA`, `RSI`, `ATR`, and `OBV` depending on size. +- The public claim should therefore be read as "often faster on selected indicators," not "faster everywhere." +- When publishing performance statements, point readers to the raw JSON artifact, not just the summary table. +- The artifact now includes per-run samples, variance stats, and Python-tracked allocation snapshots for each compared indicator. + +## Reproducible Perf Artifacts + +Use the perf-contract runner when you want a compact set of machine-readable +artifacts for single-series latency, batch throughput, streaming throughput, +and hotspot attribution in one directory: + +```bash +uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest --skip-talib +``` + +That command writes: + +- `indicator_latency.json` — canonical-fixture timings for the benchmark suite indicators +- `batch.json` — 2-D batch throughput plus grouped multi-indicator timings +- `streaming.json` — streaming update throughput vs batch baselines +- `runtime_hotspots.json` — ranked hotspot report with reference speedups +- `manifest.json` — runtime/git metadata plus hashes for the generated artifacts + +For CI or local guardrails, validate the hotspot report with: + +```bash +uv run python benchmarks/check_hotspot_regression.py --input benchmarks/artifacts/latest/runtime_hotspots.json +``` + +--- + +## Speed comparison (100k bars, median µs — lower is better) + +The speed table includes **all 62 indicators**. **Number** = median µs; **N/A** = library does not support that indicator. To regenerate: run the full suite, then `uv run python benchmarks/benchmark_table.py`. + +| Indicator | ferro_ta | talib | pandas_ta | ta | tulipy | finta | +|-----------|--------:|--------:|--------:|--------:|--------:|--------:| +| SMA | 256 | 327 | 425 | 798 | 338 | 856 | +| EMA | 369 | 365 | 427 | 641 | 358 | 722 | +| WMA | 257 | 356 | 433 | N/A | 356 | 112422 | +| DEMA | 444 | 588 | 670 | N/A | 335 | 1830 | +| TEMA | 437 | 768 | 866 | N/A | 358 | 3481 | +| T3 | 462 | 407 | 478 | N/A | N/A | 496 | +| TRIMA | 598 | 400 | 474 | N/A | 386 | 1722 | +| KAMA | 992 | 369 | 140751 | N/A | 367 | 2501 | +| HULL_MA | 547 | N/A | 957 | N/A | 372 | 329392 | +| VWMA | 376 | N/A | 669 | N/A | 391 | N/A | +| MIDPOINT | 1345 | 4685 | N/A | N/A | N/A | N/A | +| MIDPRICE | 1273 | 831 | N/A | N/A | N/A | N/A | +| RSI | 653 | 647 | 728 | 1762 | 404 | 2429 | +| MACD | 833 | 793 | 1058 | 1657 | 423 | 1726 | +| STOCH | 2445 | 941 | 1253 | 3233 | 901 | 3321 | +| CCI | 918 | 1029 | 1122 | 367074 | 676 | 321471 | +| WILLR | 1303 | 750 | 859 | 3409 | 775 | 3575 | +| AROON | 1418 | 587 | 1322 | 130842 | 737 | N/A | +| AROONOSC | 1464 | 586 | N/A | N/A | 773 | N/A | +| ADX | 855 | 746 | 27637 | 321625 | 614 | N/A | +| MOM | 189 | 180 | 254 | N/A | 186 | 352 | +| ROC | 578 | 204 | 272 | 361 | 202 | 463 | +| CMO | 876 | 634 | 707 | N/A | 312 | 2301 | +| PPO | 391 | 538 | 1045 | N/A | 380 | 2395 | +| TRIX | 488 | 831 | 1831 | 1891 | 426 | 1773 | +| TSF | 1519 | 678 | N/A | N/A | 363 | N/A | +| ULTOSC | 2069 | 619 | N/A | 14142 | 588 | N/A | +| BOP | 249 | 228 | 361 | N/A | 226 | N/A | +| PLUS_DI | 794 | 629 | 26792 | N/A | 690 | N/A | +| MINUS_DI | 796 | 600 | N/A | N/A | 642 | N/A | +| BBANDS | 345 | 581 | 1079 | 2163 | 406 | 2432 | +| ATR | 640 | 660 | 800 | 157763 | 370 | 6835 | +| NATR | 722 | 662 | 782 | N/A | 396 | N/A | +| TRANGE | 217 | 205 | 374 | N/A | 199 | 6606 | +| STDDEV | 611 | 408 | 461 | N/A | 400 | 1552 | +| VAR | 1281 | 357 | 398 | N/A | 417 | N/A | +| SAR | 520 | 459 | N/A | N/A | 454 | N/A | +| KELTNER_CHANNELS | 926 | N/A | 1062 | 2369 | N/A | N/A | +| DONCHIAN | 2399 | N/A | 3334 | 3145 | N/A | N/A | +| SUPERTREND | 1242 | N/A | 638613 | N/A | N/A | N/A | +| CHOPPINESS_INDEX | 2442 | N/A | 4892 | N/A | N/A | N/A | +| OBV | 482 | 475 | 592 | 496 | 515 | 4646 | +| AD | 271 | 282 | 424 | 615 | 291 | N/A | +| ADOSC | 482 | 409 | 544 | N/A | 376 | N/A | +| MFI | 350 | 779 | 925 | 433698 | 692 | 401076 | +| VWAP | 288 | N/A | 11460 | N/A | N/A | 880 | +| AVGPRICE | 215 | 211 | N/A | N/A | 229 | N/A | +| MEDPRICE | 203 | 188 | N/A | N/A | 197 | 445 | +| TYPPRICE | 195 | 205 | N/A | N/A | 204 | 435 | +| WCLPRICE | 199 | 197 | N/A | N/A | 210 | 292 | +| SQRT | 204 | 208 | N/A | N/A | 199 | N/A | +| LOG10 | 434 | 408 | N/A | N/A | 411 | N/A | +| ADD | 188 | 186 | N/A | N/A | 189 | N/A | +| LINEARREG | 1555 | 704 | N/A | N/A | 368 | N/A | +| LINEARREG_SLOPE | 1548 | 665 | N/A | N/A | 370 | N/A | +| CORREL | 4277 | 413 | N/A | N/A | N/A | N/A | +| BETA | 5226 | 483 | N/A | N/A | N/A | N/A | +| HT_DCPERIOD | 10864 | 4187 | N/A | N/A | N/A | N/A | +| HT_TRENDMODE | 10984 | 23020 | N/A | N/A | N/A | N/A | +| CDLENGULFING | 308 | 617 | N/A | N/A | N/A | N/A | +| CDLDOJI | 273 | 312 | N/A | N/A | N/A | N/A | +| CDLHAMMER | 304 | 1418 | N/A | N/A | N/A | N/A | + +*Apple M3 Max, Python 3.13; 273 passed, 121 skipped (unsupported = N/A). Regenerate with [Running benchmarks](#running-benchmarks).* + +**Takeaways:** + +- **`ta`** is 20–350× slower on ATR, CCI, ADX, MFI (O(n²) Python loops). +- **ferro-ta** is often materially faster than **pandas-ta** on the checked-in 100k-bar table. +- **TA-Lib** and **Tulipy** (C extensions) are strong; ferro-ta is competitive and avoids native dependencies. + +--- + +## Running benchmarks + +```bash +# Full speed suite (100k bars, all indicator × library pairs) — writes results.json +uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v + +# Head-to-head only (12 indicators × ferro_ta) — quick check +uv run pytest benchmarks/test_speed.py --benchmark-only -k "test_head_to_head" -v + +# Large-dataset scaling only (ferro_ta at 100k) +uv run pytest benchmarks/test_speed.py --benchmark-only -k "test_large_dataset" -v + +# Regenerate the Speed Comparison markdown table from results.json +uv run python benchmarks/benchmark_table.py + +# TA-Lib head-to-head with machine/runtime/build metadata, per-run samples, +# variance stats, and Python-tracked allocation snapshots +uv run python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json + +# Selected derivatives analytics comparison (BSM price, IV, Greeks, Black-76) +# against built-in analytical references plus optional installed libraries +uv run python benchmarks/bench_derivatives_compare.py --sizes 1000 10000 --json benchmark_derivatives_compare.json + +# Optional regression check used in CI +uv run python benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json + +# Batch throughput + grouped multi-indicator calls +uv run python benchmarks/bench_batch.py --samples 100000 --series 100 --json batch_benchmark.json + +# Streaming update throughput vs batch baselines +uv run python benchmarks/bench_streaming.py --bars 100000 --json streaming_benchmark.json + +# Ranked hotspot attribution against bundled reference implementations +uv run python benchmarks/profile_runtime_hotspots.py --json runtime_hotspots.json + +# Portable vs SIMD-enabled build comparison +uv run python benchmarks/bench_simd.py --json simd_benchmark.json + +# One-shot perf artifact bundle +uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest +``` + +Without `uv`: use `pytest` and `python` from the same environment where `ferro_ta` and optional libs (e.g. `talib`, `pandas_ta`, `ta`, `tulipy`, `finta`) are installed. + +### Derivatives analytics + +`benchmarks/bench_derivatives_compare.py` focuses on selected options-analytics +paths rather than the full surface area: + +- `BSM` call pricing +- call implied-volatility recovery +- call Greeks +- `Black-76` call pricing + +The script always includes two analytical baselines: + +- `reference_numpy` — pure NumPy formulas with vectorized IV bisection +- `reference_python_loop` — scalar `math`-based reference for sanity checking + +If `py_vollib` is installed, it is added automatically as an extra baseline. +The output JSON includes runtime/build metadata, per-run timing samples, +variance stats, and Python-tracked peak allocation snapshots. + +### WASM + +From the `wasm/` directory: + +```bash +wasm-pack build --target nodejs --out-dir pkg +node bench.js --json ../wasm_benchmark.json +``` + +--- + +## Indicator coverage + +### Overlap (12) +`SMA` `EMA` `WMA` `DEMA` `TEMA` `T3` `TRIMA` `KAMA` `HULL_MA` `VWMA` `MIDPOINT` `MIDPRICE` + +### Momentum (18) +`RSI` `MACD` `STOCH` `CCI` `WILLR` `AROON` `AROONOSC` `ADX` `MOM` `ROC` `CMO` `PPO` `TRIX` `TSF` `ULTOSC` `BOP` `PLUS_DI` `MINUS_DI` + +### Volatility (11) +`BBANDS` `ATR` `NATR` `TRANGE` `STDDEV` `VAR` `SAR` `KELTNER_CHANNELS` `DONCHIAN` `SUPERTREND` `CHOPPINESS_INDEX` + +### Volume (5) +`OBV` `AD` `ADOSC` `MFI` `VWAP` + +### Price Transform (4) +`AVGPRICE` `MEDPRICE` `TYPPRICE` `WCLPRICE` + +### Math (3) +`SQRT` `LOG10` `ADD` + +### Statistics (4) +`LINEARREG` `LINEARREG_SLOPE` `CORREL` `BETA` + +### Cycle (2) +`HT_DCPERIOD` `HT_TRENDMODE` + +### Candlestick patterns (3) +`CDLENGULFING` `CDLDOJI` `CDLHAMMER` + +--- + +## Accuracy results + +Accuracy is tested separately; ferro_ta is the reference. + +- **243 pairs pass** (allclose or correlation). +- **138 pairs skipped** (known formula/anchoring/scaling differences). +- **0 failures.** + +### Known structural differences + +| Pair | Reason | +|------|--------| +| CMO vs talib/pandas_ta/finta | ferro-ta CMO uses different smoothing variant | +| BBANDS vs finta | finta normalizes bands differently | +| ATR vs finta | finta uses simple TR instead of Wilder smoothing | +| VWAP vs pandas_ta | pandas_ta anchors to session start | +| HT_TRENDMODE vs talib | Hilbert Transform seed divergence | +| RSI vs ta/finta | ta/finta use SMA warmup vs Wilder EMA | +| Tulipy ROC | Fraction (0.01 = 1%) vs ferro-ta (1.0 = 1%) | +| Tulipy BBANDS | (lower, mid, upper) order differs from ferro-ta | + +```bash +# Accuracy tests (62 indicators × 6 libraries) +uv run pytest benchmarks/test_accuracy.py -v +``` + +--- + +## Data generator + +`benchmarks/data_generator.py`: + +- **`generate_ohlcv(size)`** — dict of C-contiguous `float64` arrays: `open`, `high`, `low`, `close`, `volume`. High ≥ close ≥ low > 0; volume > 0. +- **`get_pandas_ohlcv(data)`** — DataFrame with DatetimeIndex for pandas-ta and finta. + +Pre-built: `SMALL`, `MEDIUM`, `LARGE` (and `*_DF` variants). + +--- + +## Library compatibility + +Detailed notes per library: + +- [TA-Lib](../docs/compatibility/talib.md) +- [pandas-ta](../docs/compatibility/pandas_ta.md) +- [ta](../docs/compatibility/ta.md) +- [Tulipy](../docs/compatibility/tulipy.md) +- [finta](../docs/compatibility/finta.md) diff --git a/ferro-ta-main/benchmarks/__init__.py b/ferro-ta-main/benchmarks/__init__.py new file mode 100644 index 0000000..16f9fa1 --- /dev/null +++ b/ferro-ta-main/benchmarks/__init__.py @@ -0,0 +1 @@ +"""benchmarks package — cross-library accuracy and speed comparison suite.""" diff --git a/ferro-ta-main/benchmarks/artifacts/latest/batch.json b/ferro-ta-main/benchmarks/artifacts/latest/batch.json new file mode 100644 index 0000000..58d152d --- /dev/null +++ b/ferro-ta-main/benchmarks/artifacts/latest/batch.json @@ -0,0 +1,71 @@ +{ + "metadata": { + "suite": "batch", + "runtime": { + "generated_at_utc": "2026-03-23T20:25:58.345834+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "n_samples": 100000, + "n_series": 100, + "total_bars": 10000000, + "seed": 42 + } + }, + "results": [ + { + "indicator": "SMA", + "parallel_ms": 37.86, + "sequential_ms": 43.5625, + "loop_ms": 17.8136, + "parallel_speedup_vs_loop": 0.4705, + "sequential_speedup_vs_loop": 0.4089 + }, + { + "indicator": "RSI", + "parallel_ms": 40.9229, + "sequential_ms": 79.5345, + "loop_ms": 53.3368, + "parallel_speedup_vs_loop": 1.3033, + "sequential_speedup_vs_loop": 0.6706 + }, + { + "indicator": "ATR", + "parallel_ms": 91.76, + "sequential_ms": 130.1404, + "loop_ms": 99.5885, + "parallel_speedup_vs_loop": 1.0853, + "sequential_speedup_vs_loop": 0.7652 + }, + { + "indicator": "ADX", + "parallel_ms": 100.1362, + "sequential_ms": 149.3412, + "loop_ms": 125.3319, + "parallel_speedup_vs_loop": 1.2516, + "sequential_speedup_vs_loop": 0.8392 + } + ], + "grouped_results": [ + { + "case": "close_bundle_3", + "grouped_ms": 0.652, + "separate_ms": 0.9124, + "speedup_vs_separate": 1.3994 + }, + { + "case": "hlc_bundle_3", + "grouped_ms": 1.4784, + "separate_ms": 3.3724, + "speedup_vs_separate": 2.2811 + } + ] +} diff --git a/ferro-ta-main/benchmarks/artifacts/latest/bench_backtest_results.json b/ferro-ta-main/benchmarks/artifacts/latest/bench_backtest_results.json new file mode 100644 index 0000000..1404773 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/benchmarks/artifacts/latest/benchmark_derivatives_compare.json b/ferro-ta-main/benchmarks/artifacts/latest/benchmark_derivatives_compare.json new file mode 100644 index 0000000..20f88c0 --- /dev/null +++ b/ferro-ta-main/benchmarks/artifacts/latest/benchmark_derivatives_compare.json @@ -0,0 +1,1162 @@ +{ + "schema_version": 1, + "command": "python benchmarks/bench_derivatives_compare.py --sizes 1000 10000 --accuracy-size 512 --json benchmarks/artifacts/latest/benchmark_derivatives_compare.json", + "n_warmup": 1, + "n_runs": 7, + "accuracy_size": 512, + "sizes": [ + 1000, + 10000 + ], + "metadata": { + "suite": "benchmark_derivatives_compare", + "runtime": { + "generated_at_utc": "2026-03-24T05:39:25.642936+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": "0382c4e3024c96809ffd89dd76d820efcd56479d", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.91.1 (ed61e7d7e 2025-11-07) (Homebrew)\nbinary: rustc\ncommit-hash: ed61e7d7e242494fb7057f2657300d9e77bb4fcb\ncommit-date: 2025-11-07\nhost: aarch64-apple-darwin\nrelease: 1.91.1\nLLVM version: 21.1.5", + "cargo": "cargo 1.91.1 (Homebrew)", + "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.3", + "py_vollib": null + }, + "dataset": { + "generator": "synthetic_option_chain", + "speed_sizes": [ + 1000, + 10000 + ], + "accuracy_size": 512, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": 42, + "ranges": { + "spot": [ + 80.0, + 120.0 + ], + "strike": [ + 70.0, + 130.0 + ], + "rate": [ + 0.0, + 0.07 + ], + "carry": [ + 0.0, + 0.03 + ], + "time_to_expiry_years": [ + 0.019178082191780823, + 2.0 + ], + "volatility": [ + 0.08, + 0.65 + ] + } + }, + "methodology": { + "warmup_runs": 1, + "measured_runs": 7, + "reported_metric": "median_ms", + "speed_metric": "contracts_per_second", + "accuracy_reference": "Scalar analytical Black-Scholes-Merton and Black-76 formulas using math.erf; IV accuracy is measured as repriced error from the recovered volatility because direct volatility differences can be unstable on low-vega contracts.", + "input_layout_notes": "Benchmarks use contiguous float64 arrays. If your workload passes non-contiguous arrays or mixed dtypes, benchmark that path separately.", + "allocation_notes": "python_peak_allocation_bytes is a tracemalloc snapshot of Python-tracked allocations only; it does not measure native RSS.", + "provider_notes": "reference_python_loop and py_vollib are scalar baselines and are size-capped in the speed table to keep runtime reasonable." + }, + "providers": [ + { + "name": "ferro_ta", + "kind": "project", + "note": "Rust-backed vectorized implementation.", + "max_speed_size": null, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + }, + { + "name": "reference_numpy", + "kind": "reference", + "note": "Pure NumPy analytical formulas with vectorized IV bisection.", + "max_speed_size": null, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + }, + { + "name": "reference_python_loop", + "kind": "reference", + "note": "Scalar math-loop analytical baseline; useful for accuracy sanity checks.", + "max_speed_size": 1000, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + } + ] + }, + "accuracy": { + "summary": [ + { + "case": "bsm_call_price", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.6274806e-05 + }, + { + "case": "bsm_call_iv", + "best_provider": "reference_python_loop", + "best_max_abs_error": 1e-10, + "worst_provider": "reference_numpy", + "worst_max_abs_error": 1.6276264e-05 + }, + { + "case": "bsm_call_greeks", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.7335858e-05 + }, + { + "case": "black76_call_price", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.6274806e-05 + } + ], + "results": [ + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6275836e-05, + "mean_abs_error": 5.249474e-06, + "rmse": 6.489527e-06, + "max_rel_error": 0.475843909884 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6276264e-05, + "mean_abs_error": 5.249487e-06, + "rmse": 6.489634e-06, + "max_rel_error": 0.009628987742 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1e-10, + "mean_abs_error": 4.5e-11, + "rmse": 5.3e-11, + "max_rel_error": 0.00547208151 + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 1.7335858e-05, + "mean_abs_error": 8.64081e-07, + "rmse": 2.444399e-06, + "max_rel_error": 0.002644156321, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 1.7335858e-05, + "mean_abs_error": 8.64081e-07, + "rmse": 2.444399e-06, + "max_rel_error": 0.002644156321, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0 + } + ] + }, + "speed": { + "summary": [ + { + "case": "bsm_call_price", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0336, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0336, + "contracts_per_s": 29761904.76 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0456, + "contracts_per_s": 21929824.56 + }, + { + "provider": "reference_python_loop", + "median_ms": 0.8157, + "contracts_per_s": 1225940.91 + } + ] + }, + { + "case": "bsm_call_price", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.2752, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.2752, + "contracts_per_s": 36337209.3 + }, + { + "provider": "reference_numpy", + "median_ms": 0.3109, + "contracts_per_s": 32164683.18 + } + ] + }, + { + "case": "bsm_call_iv", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.4121, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.4121, + "contracts_per_s": 2426595.49 + }, + { + "provider": "reference_numpy", + "median_ms": 1.8036, + "contracts_per_s": 554446.66 + }, + { + "provider": "reference_python_loop", + "median_ms": 13.4274, + "contracts_per_s": 74474.58 + } + ] + }, + { + "case": "bsm_call_iv", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 4.0739, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 4.0739, + "contracts_per_s": 2454650.34 + }, + { + "provider": "reference_numpy", + "median_ms": 11.7949, + "contracts_per_s": 847824.06 + } + ] + }, + { + "case": "bsm_call_greeks", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0497, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0497, + "contracts_per_s": 20120724.35 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0871, + "contracts_per_s": 11481056.26 + }, + { + "provider": "reference_python_loop", + "median_ms": 1.189, + "contracts_per_s": 841042.89 + } + ] + }, + { + "case": "bsm_call_greeks", + "size": 10000, + "fastest_provider": "reference_numpy", + "fastest_median_ms": 0.572, + "ranking": [ + { + "provider": "reference_numpy", + "median_ms": 0.572, + "contracts_per_s": 17482517.48 + }, + { + "provider": "ferro_ta", + "median_ms": 0.7486, + "contracts_per_s": 13358268.77 + } + ] + }, + { + "case": "black76_call_price", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0254, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0254, + "contracts_per_s": 39370078.74 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0384, + "contracts_per_s": 26041666.67 + }, + { + "provider": "reference_python_loop", + "median_ms": 0.7358, + "contracts_per_s": 1359064.96 + } + ] + }, + { + "case": "black76_call_price", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.2184, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.2184, + "contracts_per_s": 45787545.79 + }, + { + "provider": "reference_numpy", + "median_ms": 0.2823, + "contracts_per_s": 35423308.54 + } + ] + } + ], + "results": [ + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0336, + "contracts_per_s": 29761904.76, + "runs_ms": [ + 0.0337, + 0.0404, + 0.0336, + 0.033, + 0.0332, + 0.0329, + 0.0361 + ], + "stats": { + "median_ms": 0.0336, + "mean_ms": 0.0347, + "min_ms": 0.0329, + "max_ms": 0.0404, + "stddev_ms": 0.0027, + "cv_pct": 7.901 + }, + "python_peak_allocation_bytes": 17861, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0456, + "contracts_per_s": 21929824.56, + "runs_ms": [ + 0.0468, + 0.0456, + 0.0483, + 0.045, + 0.0548, + 0.0452, + 0.0449 + ], + "stats": { + "median_ms": 0.0456, + "mean_ms": 0.0472, + "min_ms": 0.0449, + "max_ms": 0.0548, + "stddev_ms": 0.0036, + "cv_pct": 7.526 + }, + "python_peak_allocation_bytes": 115672, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 0.8157, + "contracts_per_s": 1225940.91, + "runs_ms": [ + 0.8154, + 0.8078, + 0.8184, + 0.8265, + 0.8157, + 0.8155, + 0.8267 + ], + "stats": { + "median_ms": 0.8157, + "mean_ms": 0.818, + "min_ms": 0.8078, + "max_ms": 0.8267, + "stddev_ms": 0.0067, + "cv_pct": 0.82 + }, + "python_peak_allocation_bytes": 39464, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.2752, + "contracts_per_s": 36337209.3, + "runs_ms": [ + 0.2632, + 0.2812, + 0.2666, + 0.2666, + 0.281, + 0.2752, + 0.276 + ], + "stats": { + "median_ms": 0.2752, + "mean_ms": 0.2728, + "min_ms": 0.2632, + "max_ms": 0.2812, + "stddev_ms": 0.0073, + "cv_pct": 2.684 + }, + "python_peak_allocation_bytes": 17861, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.3109, + "contracts_per_s": 32164683.18, + "runs_ms": [ + 0.304, + 0.315, + 0.3261, + 0.309, + 0.3218, + 0.3109, + 0.3084 + ], + "stats": { + "median_ms": 0.3109, + "mean_ms": 0.3136, + "min_ms": 0.304, + "max_ms": 0.3261, + "stddev_ms": 0.0079, + "cv_pct": 2.515 + }, + "python_peak_allocation_bytes": 1132672, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.4121, + "contracts_per_s": 2426595.49, + "runs_ms": [ + 0.4309, + 0.4121, + 0.4068, + 0.418, + 0.4082, + 0.4285, + 0.4093 + ], + "stats": { + "median_ms": 0.4121, + "mean_ms": 0.4163, + "min_ms": 0.4068, + "max_ms": 0.4309, + "stddev_ms": 0.0099, + "cv_pct": 2.378 + }, + "python_peak_allocation_bytes": 20709, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 1.8036, + "contracts_per_s": 554446.66, + "runs_ms": [ + 1.8522, + 1.8036, + 1.8552, + 1.7976, + 1.7981, + 1.8119, + 1.7963 + ], + "stats": { + "median_ms": 1.8036, + "mean_ms": 1.8164, + "min_ms": 1.7963, + "max_ms": 1.8552, + "stddev_ms": 0.026, + "cv_pct": 1.434 + }, + "python_peak_allocation_bytes": 149448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 13.4274, + "contracts_per_s": 74474.58, + "runs_ms": [ + 13.4787, + 13.4274, + 13.4138, + 13.4148, + 13.4643, + 13.6664, + 13.3763 + ], + "stats": { + "median_ms": 13.4274, + "mean_ms": 13.4631, + "min_ms": 13.3763, + "max_ms": 13.6664, + "stddev_ms": 0.0959, + "cv_pct": 0.712 + }, + "python_peak_allocation_bytes": 39584, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 4.0739, + "contracts_per_s": 2454650.34, + "runs_ms": [ + 4.0739, + 4.0792, + 3.9101, + 4.0077, + 4.2279, + 4.076, + 3.9563 + ], + "stats": { + "median_ms": 4.0739, + "mean_ms": 4.0473, + "min_ms": 3.9101, + "max_ms": 4.2279, + "stddev_ms": 0.1032, + "cv_pct": 2.549 + }, + "python_peak_allocation_bytes": 82830, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 11.7949, + "contracts_per_s": 847824.06, + "runs_ms": [ + 11.713, + 11.7014, + 11.8743, + 11.7949, + 11.4815, + 11.8635, + 11.8188 + ], + "stats": { + "median_ms": 11.7949, + "mean_ms": 11.7496, + "min_ms": 11.4815, + "max_ms": 11.8743, + "stddev_ms": 0.1359, + "cv_pct": 1.157 + }, + "python_peak_allocation_bytes": 1463448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0497, + "contracts_per_s": 20120724.35, + "runs_ms": [ + 0.0558, + 0.0497, + 0.0486, + 0.0503, + 0.051, + 0.0494, + 0.0486 + ], + "stats": { + "median_ms": 0.0497, + "mean_ms": 0.0505, + "min_ms": 0.0486, + "max_ms": 0.0558, + "stddev_ms": 0.0025, + "cv_pct": 4.902 + }, + "python_peak_allocation_bytes": 41928, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0871, + "contracts_per_s": 11481056.26, + "runs_ms": [ + 0.0883, + 0.0871, + 0.0876, + 0.0871, + 0.0868, + 0.0864, + 0.1533 + ], + "stats": { + "median_ms": 0.0871, + "mean_ms": 0.0967, + "min_ms": 0.0864, + "max_ms": 0.1533, + "stddev_ms": 0.025, + "cv_pct": 25.847 + }, + "python_peak_allocation_bytes": 138128, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 1.189, + "contracts_per_s": 841042.89, + "runs_ms": [ + 1.243, + 1.3353, + 1.189, + 1.1827, + 1.1821, + 1.1827, + 1.2165 + ], + "stats": { + "median_ms": 1.189, + "mean_ms": 1.2188, + "min_ms": 1.1821, + "max_ms": 1.3353, + "stddev_ms": 0.0563, + "cv_pct": 4.619 + }, + "python_peak_allocation_bytes": 199408, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.7486, + "contracts_per_s": 13358268.77, + "runs_ms": [ + 0.7306, + 0.7328, + 0.7688, + 0.732, + 0.7792, + 0.7486, + 0.8081 + ], + "stats": { + "median_ms": 0.7486, + "mean_ms": 0.7572, + "min_ms": 0.7306, + "max_ms": 0.8081, + "stddev_ms": 0.0295, + "cv_pct": 3.897 + }, + "python_peak_allocation_bytes": 401848, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.572, + "contracts_per_s": 17482517.48, + "runs_ms": [ + 0.6104, + 0.5705, + 0.5715, + 0.5817, + 0.572, + 0.5747, + 0.5708 + ], + "stats": { + "median_ms": 0.572, + "mean_ms": 0.5788, + "min_ms": 0.5705, + "max_ms": 0.6104, + "stddev_ms": 0.0145, + "cv_pct": 2.499 + }, + "python_peak_allocation_bytes": 1362128, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0254, + "contracts_per_s": 39370078.74, + "runs_ms": [ + 0.0286, + 0.0263, + 0.0258, + 0.0254, + 0.0253, + 0.0253, + 0.0254 + ], + "stats": { + "median_ms": 0.0254, + "mean_ms": 0.026, + "min_ms": 0.0253, + "max_ms": 0.0286, + "stddev_ms": 0.0012, + "cv_pct": 4.639 + }, + "python_peak_allocation_bytes": 14717, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0384, + "contracts_per_s": 26041666.67, + "runs_ms": [ + 0.0395, + 0.0385, + 0.0387, + 0.0383, + 0.0384, + 0.0382, + 0.0383 + ], + "stats": { + "median_ms": 0.0384, + "mean_ms": 0.0385, + "min_ms": 0.0382, + "max_ms": 0.0395, + "stddev_ms": 0.0005, + "cv_pct": 1.22 + }, + "python_peak_allocation_bytes": 99448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 0.7358, + "contracts_per_s": 1359064.96, + "runs_ms": [ + 0.7271, + 0.7251, + 0.845, + 0.7809, + 0.7345, + 0.7358, + 0.7418 + ], + "stats": { + "median_ms": 0.7358, + "mean_ms": 0.7558, + "min_ms": 0.7251, + "max_ms": 0.845, + "stddev_ms": 0.0436, + "cv_pct": 5.769 + }, + "python_peak_allocation_bytes": 39416, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.2184, + "contracts_per_s": 45787545.79, + "runs_ms": [ + 0.2286, + 0.2183, + 0.219, + 0.2184, + 0.2223, + 0.218, + 0.2176 + ], + "stats": { + "median_ms": 0.2184, + "mean_ms": 0.2203, + "min_ms": 0.2176, + "max_ms": 0.2286, + "stddev_ms": 0.004, + "cv_pct": 1.8 + }, + "python_peak_allocation_bytes": 14717, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.2823, + "contracts_per_s": 35423308.54, + "runs_ms": [ + 0.2823, + 0.276, + 0.275, + 0.3064, + 0.3196, + 0.2835, + 0.2734 + ], + "stats": { + "median_ms": 0.2823, + "mean_ms": 0.288, + "min_ms": 0.2734, + "max_ms": 0.3196, + "stddev_ms": 0.0179, + "cv_pct": 6.201 + }, + "python_peak_allocation_bytes": 972448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + } + ] + } +} diff --git a/ferro-ta-main/benchmarks/artifacts/latest/indicator_latency.json b/ferro-ta-main/benchmarks/artifacts/latest/indicator_latency.json new file mode 100644 index 0000000..0ecfaaa --- /dev/null +++ b/ferro-ta-main/benchmarks/artifacts/latest/indicator_latency.json @@ -0,0 +1,163 @@ +{ + "metadata": { + "suite": "indicator_latency", + "runtime": { + "generated_at_utc": "2026-03-23T20:25:52.160357+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "fixtures": [ + { + "path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "size_bytes": 75586, + "sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c" + } + ], + "dataset": { + "fixture": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "bars": 2000, + "rounds": 5 + } + }, + "results": [ + { + "name": "VAR_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0229 + }, + { + "name": "STOCH", + "inputs": "hlc", + "kwargs": {}, + "elapsed_ms": 0.0201 + }, + { + "name": "WILLR_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0201 + }, + { + "name": "CCI_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0163 + }, + { + "name": "ADX_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.015 + }, + { + "name": "MACD", + "inputs": "close", + "kwargs": {}, + "elapsed_ms": 0.0135 + }, + { + "name": "ATR_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0105 + }, + { + "name": "RSI_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0098 + }, + { + "name": "STDDEV_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.009 + }, + { + "name": "BETA_5", + "inputs": "pair_hl", + "kwargs": { + "timeperiod": 5 + }, + "elapsed_ms": 0.0083 + }, + { + "name": "CORREL_30", + "inputs": "pair_hl", + "kwargs": { + "timeperiod": 30 + }, + "elapsed_ms": 0.0076 + }, + { + "name": "BBANDS_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0055 + }, + { + "name": "LINEARREG_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0055 + }, + { + "name": "TSF_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0054 + }, + { + "name": "LINEARREG_SLOPE_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0052 + }, + { + "name": "EMA_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.005 + }, + { + "name": "SMA_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0026 + } + ] +} diff --git a/ferro-ta-main/benchmarks/artifacts/latest/manifest.json b/ferro-ta-main/benchmarks/artifacts/latest/manifest.json new file mode 100644 index 0000000..1befe60 --- /dev/null +++ b/ferro-ta-main/benchmarks/artifacts/latest/manifest.json @@ -0,0 +1,67 @@ +{ + "metadata": { + "suite": "perf_contract", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:40.776130+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "fixtures": [ + { + "path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "size_bytes": 75586, + "sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c" + } + ], + "output_dir": "benchmarks/artifacts/latest" + }, + "artifacts": { + "indicator_latency": { + "path": "benchmarks/artifacts/latest/indicator_latency.json", + "size_bytes": 3217, + "sha256": "43b88a50a4d7f91e30ff8e57dbf859ae5e76ecabaaf05cfcb7d8db67df920f7f" + }, + "batch": { + "path": "benchmarks/artifacts/latest/batch.json", + "size_bytes": 1701, + "sha256": "bc900c885c48ec1903ea4870ca1de8cb9f33c609d2cefa4688cbdd18cb977f11" + }, + "streaming": { + "path": "benchmarks/artifacts/latest/streaming.json", + "size_bytes": 1944, + "sha256": "925ba1be66d0d499daa81dfc148b03ac325ad685ce0c71e28ca1fc6927f16415" + }, + "runtime_hotspots": { + "path": "benchmarks/artifacts/latest/runtime_hotspots.json", + "size_bytes": 2366, + "sha256": "920553b14b545f211b119c099ec59885de8b9e8056271cb2d2ac34c0c69b0906" + }, + "simd": { + "path": "benchmarks/artifacts/latest/simd.json", + "size_bytes": 7700, + "sha256": "d48943a5dfcf4f8d8d2ca42f0004f02f9fc894de7477791b686231da665e3335" + }, + "benchmark_vs_talib": { + "path": "benchmarks/artifacts/latest/benchmark_vs_talib.json", + "size_bytes": 5923, + "sha256": "8a4e847517f1334255353982a5266c0323bf433a1eb78dafeff808d5ad3bf7f0" + }, + "wasm": { + "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/ferro-ta-main/benchmarks/artifacts/latest/runtime_hotspots.json b/ferro-ta-main/benchmarks/artifacts/latest/runtime_hotspots.json new file mode 100644 index 0000000..a56f261 --- /dev/null +++ b/ferro-ta-main/benchmarks/artifacts/latest/runtime_hotspots.json @@ -0,0 +1,96 @@ +{ + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:02.236710+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 35.984, + "reference_ms": 944.5804, + "speedup_vs_reference": 26.25, + "share_of_suite_pct": 77.27 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 7.6624, + "reference_ms": 81.581, + "speedup_vs_reference": 10.6469, + "share_of_suite_pct": 16.45 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 2.2905, + "reference_ms": 198.2937, + "speedup_vs_reference": 86.5738, + "share_of_suite_pct": 4.92 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2872, + "reference_ms": 0.2377, + "speedup_vs_reference": 0.8275, + "share_of_suite_pct": 0.62 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1448, + "reference_ms": 0.1505, + "speedup_vs_reference": 1.0391, + "share_of_suite_pct": 0.31 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0637, + "reference_ms": 164.1752, + "speedup_vs_reference": 2575.2975, + "share_of_suite_pct": 0.14 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0553, + "reference_ms": 159.6473, + "speedup_vs_reference": 2885.1573, + "share_of_suite_pct": 0.12 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0415, + "reference_ms": 47.4665, + "speedup_vs_reference": 1143.77, + "share_of_suite_pct": 0.09 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0414, + "reference_ms": 47.921, + "speedup_vs_reference": 1157.036, + "share_of_suite_pct": 0.09 + } + ] +} diff --git a/ferro-ta-main/benchmarks/artifacts/latest/simd.json b/ferro-ta-main/benchmarks/artifacts/latest/simd.json new file mode 100644 index 0000000..3a64e88 --- /dev/null +++ b/ferro-ta-main/benchmarks/artifacts/latest/simd.json @@ -0,0 +1,285 @@ +{ + "metadata": { + "suite": "simd", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:40.566511+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + }, + "variants": [ + "portable_release", + "simd_release" + ] + }, + "results": [ + { + "name": "BETA", + "category": "rust_kernel", + "portable_ms": 0.0635, + "simd_ms": 0.0636, + "speedup_simd_vs_portable": 0.9984 + }, + { + "name": "TSF", + "category": "rust_kernel", + "portable_ms": 0.0415, + "simd_ms": 0.0417, + "speedup_simd_vs_portable": 0.9952 + }, + { + "name": "compute_many_close", + "category": "ffi_grouping", + "portable_ms": 0.1548, + "simd_ms": 0.1572, + "speedup_simd_vs_portable": 0.9847 + }, + { + "name": "iv_zscore", + "category": "python_analysis", + "portable_ms": 36.0643, + "simd_ms": 37.2041, + "speedup_simd_vs_portable": 0.9694 + }, + { + "name": "feature_matrix", + "category": "ffi_grouping", + "portable_ms": 0.2556, + "simd_ms": 0.2667, + "speedup_simd_vs_portable": 0.9584 + }, + { + "name": "iv_percentile", + "category": "python_analysis", + "portable_ms": 7.7548, + "simd_ms": 8.1565, + "speedup_simd_vs_portable": 0.9508 + }, + { + "name": "LINEARREG", + "category": "rust_kernel", + "portable_ms": 0.0416, + "simd_ms": 0.0443, + "speedup_simd_vs_portable": 0.9391 + }, + { + "name": "iv_rank", + "category": "python_analysis", + "portable_ms": 2.2813, + "simd_ms": 2.4386, + "speedup_simd_vs_portable": 0.9355 + }, + { + "name": "CORREL", + "category": "rust_kernel", + "portable_ms": 0.0552, + "simd_ms": 0.0633, + "speedup_simd_vs_portable": 0.872 + } + ], + "reports": { + "portable_release": { + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:06.920513+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 36.0643, + "reference_ms": 908.5403, + "speedup_vs_reference": 25.1922, + "share_of_suite_pct": 77.2 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 7.7548, + "reference_ms": 82.2352, + "speedup_vs_reference": 10.6045, + "share_of_suite_pct": 16.6 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 2.2813, + "reference_ms": 202.5375, + "speedup_vs_reference": 88.7803, + "share_of_suite_pct": 4.88 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2556, + "reference_ms": 0.2252, + "speedup_vs_reference": 0.8812, + "share_of_suite_pct": 0.55 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1548, + "reference_ms": 0.1508, + "speedup_vs_reference": 0.9742, + "share_of_suite_pct": 0.33 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0635, + "reference_ms": 162.8972, + "speedup_vs_reference": 2563.6148, + "share_of_suite_pct": 0.14 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0552, + "reference_ms": 163.1357, + "speedup_vs_reference": 2952.6826, + "share_of_suite_pct": 0.12 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0416, + "reference_ms": 48.0097, + "speedup_vs_reference": 1153.3863, + "share_of_suite_pct": 0.09 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0415, + "reference_ms": 47.9395, + "speedup_vs_reference": 1155.1696, + "share_of_suite_pct": 0.09 + } + ] + }, + "simd_release": { + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:25.789478+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 37.2041, + "reference_ms": 930.7842, + "speedup_vs_reference": 25.0183, + "share_of_suite_pct": 76.81 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 8.1565, + "reference_ms": 88.3639, + "speedup_vs_reference": 10.8336, + "share_of_suite_pct": 16.84 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 2.4386, + "reference_ms": 221.0389, + "speedup_vs_reference": 90.6424, + "share_of_suite_pct": 5.03 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2667, + "reference_ms": 0.2436, + "speedup_vs_reference": 0.9134, + "share_of_suite_pct": 0.55 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1572, + "reference_ms": 0.1593, + "speedup_vs_reference": 1.0135, + "share_of_suite_pct": 0.32 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0636, + "reference_ms": 172.9198, + "speedup_vs_reference": 2717.7961, + "share_of_suite_pct": 0.13 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0633, + "reference_ms": 170.0262, + "speedup_vs_reference": 2686.3776, + "share_of_suite_pct": 0.13 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0443, + "reference_ms": 50.5614, + "speedup_vs_reference": 1141.5474, + "share_of_suite_pct": 0.09 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0417, + "reference_ms": 50.9599, + "speedup_vs_reference": 1221.8259, + "share_of_suite_pct": 0.09 + } + ] + } + } +} diff --git a/ferro-ta-main/benchmarks/artifacts/latest/streaming.json b/ferro-ta-main/benchmarks/artifacts/latest/streaming.json new file mode 100644 index 0000000..e8494b6 --- /dev/null +++ b/ferro-ta-main/benchmarks/artifacts/latest/streaming.json @@ -0,0 +1,73 @@ +{ + "metadata": { + "suite": "streaming", + "runtime": { + "generated_at_utc": "2026-03-23T20:25:58.628657+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "n_bars": 100000, + "seed": 2026 + } + }, + "results": [ + { + "indicator": "StreamingSMA", + "inputs": "close", + "stream_total_ms": 4.5729, + "batch_total_ms": 0.0685, + "stream_ns_per_update": 45.73, + "batch_ns_per_bar": 0.68, + "updates_per_second": 21867879.95, + "stream_over_batch_ratio": 66.7989 + }, + { + "indicator": "StreamingEMA", + "inputs": "close", + "stream_total_ms": 4.535, + "batch_total_ms": 0.1969, + "stream_ns_per_update": 45.35, + "batch_ns_per_bar": 1.97, + "updates_per_second": 22050716.65, + "stream_over_batch_ratio": 23.0301 + }, + { + "indicator": "StreamingRSI", + "inputs": "close", + "stream_total_ms": 4.6421, + "batch_total_ms": 0.4597, + "stream_ns_per_update": 46.42, + "batch_ns_per_bar": 4.6, + "updates_per_second": 21541858.52, + "stream_over_batch_ratio": 10.098 + }, + { + "indicator": "StreamingATR", + "inputs": "hlc", + "stream_total_ms": 10.2098, + "batch_total_ms": 0.4599, + "stream_ns_per_update": 102.1, + "batch_ns_per_bar": 4.6, + "updates_per_second": 9794518.83, + "stream_over_batch_ratio": 22.2012 + }, + { + "indicator": "StreamingVWAP", + "inputs": "hlcv", + "stream_total_ms": 12.5109, + "batch_total_ms": 0.1027, + "stream_ns_per_update": 125.11, + "batch_ns_per_bar": 1.03, + "updates_per_second": 7993046.05, + "stream_over_batch_ratio": 121.7603 + } + ] +} diff --git a/ferro-ta-main/benchmarks/artifacts/latest/wasm.json b/ferro-ta-main/benchmarks/artifacts/latest/wasm.json new file mode 100644 index 0000000..32a796a --- /dev/null +++ b/ferro-ta-main/benchmarks/artifacts/latest/wasm.json @@ -0,0 +1,46 @@ +{ + "metadata": { + "suite": "wasm", + "runtime": { + "generated_at_utc": "2026-03-23T20:15:04.885Z", + "node_version": "v25.8.1", + "platform": "darwin", + "arch": "arm64" + }, + "dataset": { + "bars": 100000 + } + }, + "results": [ + { + "indicator": "SMA", + "elapsed_ms": 0.1702, + "ns_per_bar": 1.7, + "million_bars_per_second": 587.52 + }, + { + "indicator": "EMA", + "elapsed_ms": 0.2923, + "ns_per_bar": 2.92, + "million_bars_per_second": 342.08 + }, + { + "indicator": "RSI", + "elapsed_ms": 0.5962, + "ns_per_bar": 5.96, + "million_bars_per_second": 167.73 + }, + { + "indicator": "ATR", + "elapsed_ms": 0.642, + "ns_per_bar": 6.42, + "million_bars_per_second": 155.75 + }, + { + "indicator": "BBANDS", + "elapsed_ms": 1.7411, + "ns_per_bar": 17.41, + "million_bars_per_second": 57.43 + } + ] +} diff --git a/ferro-ta-main/benchmarks/bench_backtest.py b/ferro-ta-main/benchmarks/bench_backtest.py new file mode 100644 index 0000000..13068b1 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/benchmarks/bench_batch.py b/ferro-ta-main/benchmarks/bench_batch.py new file mode 100644 index 0000000..c008c41 --- /dev/null +++ b/ferro-ta-main/benchmarks/bench_batch.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path +from typing import Any + +import numpy as np + +import ferro_ta + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata + + +def _time_fn(fn, *args, rounds: int = 5, **kwargs) -> float: + fn(*args, **kwargs) + times: list[float] = [] + for _ in range(rounds): + t0 = time.perf_counter() + fn(*args, **kwargs) + times.append(time.perf_counter() - t0) + return min(times) + + +def run_batch_benchmark( + *, + n_samples: int = 100_000, + n_series: int = 100, + seed: int = 42, +) -> dict[str, Any]: + rng = np.random.default_rng(seed) + close2d = rng.uniform(100.0, 200.0, (n_samples, n_series)) + high2d = close2d + rng.uniform(0.1, 2.0, (n_samples, n_series)) + low2d = close2d - rng.uniform(0.1, 2.0, (n_samples, n_series)) + close1d = close2d[:, 0] + high1d = high2d[:, 0] + low1d = low2d[:, 0] + + batch_rows: list[dict[str, Any]] = [] + grouped_rows: list[dict[str, Any]] = [] + + indicators = [ + ( + "SMA", + lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=True), + lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=False), + lambda: [ + ferro_ta.SMA(close2d[:, j], timeperiod=14) for j in range(n_series) + ], + ), + ( + "RSI", + lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=True), + lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=False), + lambda: [ + ferro_ta.RSI(close2d[:, j], timeperiod=14) for j in range(n_series) + ], + ), + ( + "ATR", + lambda: ferro_ta.batch.batch_atr( + high2d, low2d, close2d, timeperiod=14, parallel=True + ), + lambda: ferro_ta.batch.batch_atr( + high2d, low2d, close2d, timeperiod=14, parallel=False + ), + lambda: [ + ferro_ta.ATR(high2d[:, j], low2d[:, j], close2d[:, j], timeperiod=14) + for j in range(n_series) + ], + ), + ( + "ADX", + lambda: ferro_ta.batch.batch_adx( + high2d, low2d, close2d, timeperiod=14, parallel=True + ), + lambda: ferro_ta.batch.batch_adx( + high2d, low2d, close2d, timeperiod=14, parallel=False + ), + lambda: [ + ferro_ta.ADX(high2d[:, j], low2d[:, j], close2d[:, j], timeperiod=14) + for j in range(n_series) + ], + ), + ] + + for name, parallel_fn, sequential_fn, loop_fn in indicators: + batch_parallel_s = _time_fn(parallel_fn) + batch_sequential_s = _time_fn(sequential_fn) + loop_s = _time_fn(loop_fn) + batch_rows.append( + { + "indicator": name, + "parallel_ms": round(batch_parallel_s * 1000, 4), + "sequential_ms": round(batch_sequential_s * 1000, 4), + "loop_ms": round(loop_s * 1000, 4), + "parallel_speedup_vs_loop": round(loop_s / batch_parallel_s, 4), + "sequential_speedup_vs_loop": round(loop_s / batch_sequential_s, 4), + } + ) + + grouped_cases = [ + ( + "close_bundle_3", + lambda: ferro_ta.batch.compute_many( + [ + ("SMA", {"timeperiod": 10}), + ("EMA", {"timeperiod": 12}), + ("RSI", {"timeperiod": 14}), + ], + close=close1d, + ), + lambda: ( + ferro_ta.SMA(close1d, timeperiod=10), + ferro_ta.EMA(close1d, timeperiod=12), + ferro_ta.RSI(close1d, timeperiod=14), + ), + ), + ( + "hlc_bundle_3", + lambda: ferro_ta.batch.compute_many( + [ + ("ATR", {"timeperiod": 14}), + ("ADX", {"timeperiod": 14}), + ("CCI", {"timeperiod": 14}), + ], + close=close1d, + high=high1d, + low=low1d, + ), + lambda: ( + ferro_ta.ATR(high1d, low1d, close1d, timeperiod=14), + ferro_ta.ADX(high1d, low1d, close1d, timeperiod=14), + ferro_ta.CCI(high1d, low1d, close1d, timeperiod=14), + ), + ), + ] + + for name, grouped_fn, separate_fn in grouped_cases: + grouped_s = _time_fn(grouped_fn) + separate_s = _time_fn(separate_fn) + grouped_rows.append( + { + "case": name, + "grouped_ms": round(grouped_s * 1000, 4), + "separate_ms": round(separate_s * 1000, 4), + "speedup_vs_separate": round(separate_s / grouped_s, 4), + } + ) + + return { + "metadata": benchmark_metadata( + "batch", + extra={ + "dataset": { + "n_samples": n_samples, + "n_series": n_series, + "total_bars": n_samples * n_series, + "seed": seed, + } + }, + ), + "results": batch_rows, + "grouped_results": grouped_rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Benchmark batch indicator execution.") + parser.add_argument("--samples", type=int, default=100_000) + parser.add_argument("--series", type=int, default=100) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--json", dest="json_path") + args = parser.parse_args() + + payload = run_batch_benchmark( + n_samples=args.samples, + n_series=args.series, + seed=args.seed, + ) + + dataset = payload["metadata"]["dataset"] + print( + "Batch Benchmark: " + f"{dataset['n_samples']} bars, {dataset['n_series']} series " + f"(Total: {dataset['total_bars'] / 1e6:.1f} M bars)" + ) + print("-" * 74) + print( + f"{'Indicator':<12} {'Parallel (ms)':>14} {'Sequential (ms)':>16} " + f"{'Loop (ms)':>12} {'P speedup':>10}" + ) + print("-" * 74) + for row in payload["results"]: + print( + f"{row['indicator']:<12} {row['parallel_ms']:14.1f} " + f"{row['sequential_ms']:16.1f} {row['loop_ms']:12.1f} " + f"{row['parallel_speedup_vs_loop']:10.2f}x" + ) + + if payload["grouped_results"]: + print("\nGrouped Multi-Indicator Calls") + print("-" * 64) + print( + f"{'Case':<18} {'Grouped (ms)':>14} {'Separate (ms)':>16} {'Speedup':>12}" + ) + print("-" * 64) + for row in payload["grouped_results"]: + print( + f"{row['case']:<18} {row['grouped_ms']:14.1f} " + f"{row['separate_ms']:16.1f} {row['speedup_vs_separate']:12.2f}x" + ) + + if args.json_path: + json_path = Path(args.json_path) + 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/ferro-ta-main/benchmarks/bench_derivatives_compare.py b/ferro-ta-main/benchmarks/bench_derivatives_compare.py new file mode 100644 index 0000000..388f200 --- /dev/null +++ b/ferro-ta-main/benchmarks/bench_derivatives_compare.py @@ -0,0 +1,1231 @@ +""" +Compare ferro_ta derivatives analytics against analytical references and +optional third-party libraries available in the current environment. + +The suite focuses on selected core workflows: + +- Black-Scholes-Merton call pricing +- implied-volatility recovery +- first-order Greeks +- Black-76 call pricing + +Outputs include: + +- speed timings with per-run samples and variance stats +- analytical accuracy metrics +- Python-tracked peak allocation snapshots +- machine, runtime, build, and package metadata +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import math +import sys +import time +import tracemalloc +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +from ferro_ta.analysis.options import ( + black_76_price as ft_black_76_price, +) +from ferro_ta.analysis.options import ( + greeks as ft_greeks, +) +from ferro_ta.analysis.options import ( + implied_volatility as ft_implied_volatility, +) +from ferro_ta.analysis.options import ( + option_price as ft_option_price, +) + +try: + from benchmarks.metadata import benchmark_metadata, package_versions +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata, package_versions + + +N_WARMUP = 1 +N_RUNS = 7 +DEFAULT_SIZES = [1_000, 10_000] +DEFAULT_ACCURACY_SIZE = 512 +DEFAULT_SEED = 42 +INV_SQRT_2PI = 1.0 / math.sqrt(2.0 * math.pi) + + +@dataclass(frozen=True) +class Case: + name: str + label: str + expected_key: str + component_names: tuple[str, ...] | None = None + accuracy_target: str = "expected_output" + + +@dataclass(frozen=True) +class Provider: + name: str + kind: str + note: str + functions: dict[str, Callable[[dict[str, np.ndarray]], np.ndarray]] + max_speed_size: int | None = None + + def supports(self, case_name: str) -> bool: + return case_name in self.functions + + +CASES = [ + Case("bsm_call_price", "BSM Call Price", "call_price"), + Case( + "bsm_call_iv", + "BSM Call IV Recovery", + "volatility", + accuracy_target="reconstructed_price", + ), + Case( + "bsm_call_greeks", + "BSM Call Greeks", + "call_greeks", + component_names=("delta", "gamma", "vega", "theta", "rho"), + ), + Case("black76_call_price", "Black-76 Call Price", "black76_call_price"), +] + + +def _median(values: list[float]) -> float: + ordered = sorted(values) + mid = len(ordered) // 2 + if len(ordered) % 2: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 + + +def _summary_stats(samples_ms: list[float]) -> dict[str, float]: + if not samples_ms: + return { + "median_ms": 0.0, + "mean_ms": 0.0, + "min_ms": 0.0, + "max_ms": 0.0, + "stddev_ms": 0.0, + "cv_pct": 0.0, + } + + mean_ms = sum(samples_ms) / len(samples_ms) + variance = ( + sum((sample - mean_ms) ** 2 for sample in samples_ms) / (len(samples_ms) - 1) + if len(samples_ms) > 1 + else 0.0 + ) + stddev_ms = math.sqrt(variance) + cv_pct = (stddev_ms / mean_ms * 100.0) if mean_ms else 0.0 + return { + "median_ms": round(_median(samples_ms), 4), + "mean_ms": round(mean_ms, 4), + "min_ms": round(min(samples_ms), 4), + "max_ms": round(max(samples_ms), 4), + "stddev_ms": round(stddev_ms, 4), + "cv_pct": round(cv_pct, 3), + } + + +def _timed_runs_ms( + fn: Callable[[dict[str, np.ndarray]], np.ndarray], + chain: dict[str, np.ndarray], +) -> list[float]: + for _ in range(N_WARMUP): + fn(chain) + + samples_ms: list[float] = [] + for _ in range(N_RUNS): + t0 = time.perf_counter() + fn(chain) + samples_ms.append((time.perf_counter() - t0) * 1000.0) + return samples_ms + + +def _python_peak_bytes( + fn: Callable[[dict[str, np.ndarray]], np.ndarray], + chain: dict[str, np.ndarray], +) -> int | None: + try: + tracemalloc.start() + tracemalloc.reset_peak() + fn(chain) + _, peak = tracemalloc.get_traced_memory() + return int(peak) + except Exception: + return None + finally: + tracemalloc.stop() + + +def _throughput_contracts_s(size: int, median_ms: float) -> float: + if median_ms <= 0: + return 0.0 + return size / (median_ms / 1000.0) + + +def _normal_pdf_numpy(x: np.ndarray) -> np.ndarray: + return INV_SQRT_2PI * np.exp(-0.5 * x * x) + + +def _normal_cdf_numpy(x: np.ndarray) -> np.ndarray: + abs_x = np.abs(x) + t = 1.0 / (1.0 + 0.2316419 * abs_x) + poly = ( + (((((1.330274429 * t) - 1.821255978) * t) + 1.781477937) * t - 0.356563782) * t + + 0.319381530 + ) * t + cdf = 1.0 - _normal_pdf_numpy(abs_x) * poly + return np.where(x >= 0.0, cdf, 1.0 - cdf) + + +def _normal_cdf_scalar(x: float) -> float: + return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) + + +def _normal_pdf_scalar(x: float) -> float: + return INV_SQRT_2PI * math.exp(-0.5 * x * x) + + +def _bsm_price_numpy( + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, + carry: np.ndarray, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + spot_df = np.exp(-carry * time_to_expiry) + strike_df = np.exp(-rate * time_to_expiry) + if option_type == "call": + out = spot * spot_df * _normal_cdf_numpy( + d1 + ) - strike * strike_df * _normal_cdf_numpy(d2) + else: + out = strike * strike_df * _normal_cdf_numpy( + -d2 + ) - spot * spot_df * _normal_cdf_numpy(-d1) + return np.ascontiguousarray(out, dtype=np.float64) + + +def _black76_price_numpy( + forward: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(forward / strike) + 0.5 * volatility * volatility * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + discount = np.exp(-rate * time_to_expiry) + if option_type == "call": + out = discount * ( + forward * _normal_cdf_numpy(d1) - strike * _normal_cdf_numpy(d2) + ) + else: + out = discount * ( + strike * _normal_cdf_numpy(-d2) - forward * _normal_cdf_numpy(-d1) + ) + return np.ascontiguousarray(out, dtype=np.float64) + + +def _bsm_greeks_numpy( + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, + carry: np.ndarray, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + pdf = _normal_pdf_numpy(d1) + carry_df = np.exp(-carry * time_to_expiry) + strike_df = np.exp(-rate * time_to_expiry) + + if option_type == "call": + delta = carry_df * _normal_cdf_numpy(d1) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + - rate * strike * strike_df * _normal_cdf_numpy(d2) + + carry * spot * carry_df * _normal_cdf_numpy(d1) + ) + rho = strike * time_to_expiry * strike_df * _normal_cdf_numpy(d2) + else: + delta = carry_df * (_normal_cdf_numpy(d1) - 1.0) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + + rate * strike * strike_df * _normal_cdf_numpy(-d2) + - carry * spot * carry_df * _normal_cdf_numpy(-d1) + ) + rho = -strike * time_to_expiry * strike_df * _normal_cdf_numpy(-d2) + + gamma = carry_df * pdf / (spot * sigma_sqrt_t) + vega = spot * carry_df * pdf * sqrt_t + return np.ascontiguousarray( + np.column_stack([delta, gamma, vega, theta, rho]), + dtype=np.float64, + ) + + +def _bsm_price_scalar( + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, + carry: float, +) -> float: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + spot_df = math.exp(-carry * time_to_expiry) + strike_df = math.exp(-rate * time_to_expiry) + if option_type == "call": + return spot * spot_df * _normal_cdf_scalar( + d1 + ) - strike * strike_df * _normal_cdf_scalar(d2) + return strike * strike_df * _normal_cdf_scalar( + -d2 + ) - spot * spot_df * _normal_cdf_scalar(-d1) + + +def _black76_price_scalar( + forward: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, +) -> float: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(forward / strike) + 0.5 * volatility * volatility * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + discount = math.exp(-rate * time_to_expiry) + if option_type == "call": + return discount * ( + forward * _normal_cdf_scalar(d1) - strike * _normal_cdf_scalar(d2) + ) + return discount * ( + strike * _normal_cdf_scalar(-d2) - forward * _normal_cdf_scalar(-d1) + ) + + +def _bsm_greeks_scalar( + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, + carry: float, +) -> tuple[float, float, float, float, float]: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + pdf = _normal_pdf_scalar(d1) + carry_df = math.exp(-carry * time_to_expiry) + strike_df = math.exp(-rate * time_to_expiry) + + if option_type == "call": + delta = carry_df * _normal_cdf_scalar(d1) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + - rate * strike * strike_df * _normal_cdf_scalar(d2) + + carry * spot * carry_df * _normal_cdf_scalar(d1) + ) + rho = strike * time_to_expiry * strike_df * _normal_cdf_scalar(d2) + else: + delta = carry_df * (_normal_cdf_scalar(d1) - 1.0) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + + rate * strike * strike_df * _normal_cdf_scalar(-d2) + - carry * spot * carry_df * _normal_cdf_scalar(-d1) + ) + rho = -strike * time_to_expiry * strike_df * _normal_cdf_scalar(-d2) + + gamma = carry_df * pdf / (spot * sigma_sqrt_t) + vega = spot * carry_df * pdf * sqrt_t + return delta, gamma, vega, theta, rho + + +def _implied_vol_bisection_numpy( + price: np.ndarray, + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + *, + option_type: str, + carry: np.ndarray, + lower: float = 1e-6, + upper: float = 5.0, + tolerance: float = 1e-8, + max_iterations: int = 100, +) -> np.ndarray: + lo = np.full_like(price, lower, dtype=np.float64) + hi = np.full_like(price, upper, dtype=np.float64) + mid = np.full_like(price, 0.2, dtype=np.float64) + for _ in range(max_iterations): + mid = (lo + hi) / 2.0 + estimate = _bsm_price_numpy( + spot, + strike, + rate, + time_to_expiry, + mid, + option_type=option_type, + carry=carry, + ) + too_low = estimate < price + lo = np.where(too_low, mid, lo) + hi = np.where(too_low, hi, mid) + if float(np.max(np.abs(estimate - price))) < tolerance: + break + return np.ascontiguousarray(mid, dtype=np.float64) + + +def _implied_vol_bisection_scalar( + price: float, + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + option_type: str, + carry: float, + lower: float = 1e-6, + upper: float = 5.0, + tolerance: float = 1e-10, + max_iterations: int = 100, +) -> float: + lo = lower + hi = upper + mid = 0.2 + for _ in range(max_iterations): + mid = (lo + hi) / 2.0 + estimate = _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + mid, + option_type=option_type, + carry=carry, + ) + if abs(estimate - price) < tolerance: + return mid + if estimate < price: + lo = mid + else: + hi = mid + return mid + + +def _reference_python_loop( + chain: dict[str, np.ndarray], + fn: Callable[..., float | tuple[float, ...]], + *, + include_forward: bool = False, + include_price: bool = False, +) -> np.ndarray: + rows: list[Any] = [] + for idx in range(len(chain["strike"])): + kwargs: dict[str, float | str] = { + "strike": float(chain["strike"][idx]), + "rate": float(chain["rate"][idx]), + "time_to_expiry": float(chain["time_to_expiry"][idx]), + "volatility": float(chain["volatility"][idx]), + "carry": float(chain["carry"][idx]), + } + if include_forward: + kwargs["forward"] = float(chain["forward"][idx]) + else: + kwargs["spot"] = float(chain["spot"][idx]) + if include_price: + kwargs["price"] = float(chain["call_price"][idx]) + rows.append(fn(**kwargs)) + return np.asarray(rows, dtype=np.float64) + + +def _build_chain(n: int, *, seed: int) -> dict[str, np.ndarray]: + rng = np.random.default_rng(seed) + spot = np.ascontiguousarray(rng.uniform(80.0, 120.0, size=n), dtype=np.float64) + strike = np.ascontiguousarray(rng.uniform(70.0, 130.0, size=n), dtype=np.float64) + rate = np.ascontiguousarray(rng.uniform(0.0, 0.07, size=n), dtype=np.float64) + carry = np.ascontiguousarray(rng.uniform(0.0, 0.03, size=n), dtype=np.float64) + time_to_expiry = np.ascontiguousarray( + rng.uniform(7.0 / 365.0, 2.0, size=n), dtype=np.float64 + ) + volatility = np.ascontiguousarray(rng.uniform(0.08, 0.65, size=n), dtype=np.float64) + forward = np.ascontiguousarray( + spot * np.exp((rate - carry) * time_to_expiry), + dtype=np.float64, + ) + + chain = { + "spot": spot, + "strike": strike, + "rate": rate, + "carry": carry, + "time_to_expiry": time_to_expiry, + "volatility": volatility, + "forward": forward, + } + + call_price = _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + call_greeks = _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_greeks_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + black76_call_price = _reference_python_loop( + chain, + lambda *, forward, strike, rate, time_to_expiry, volatility, carry: ( + _black76_price_scalar( + forward, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + ) + ), + include_forward=True, + ) + + chain["call_price"] = np.ascontiguousarray(call_price, dtype=np.float64) + chain["call_greeks"] = np.ascontiguousarray(call_greeks, dtype=np.float64) + chain["black76_call_price"] = np.ascontiguousarray( + black76_call_price, dtype=np.float64 + ) + return chain + + +def _ferro_ta_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_option_price( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + model="bsm", + carry=chain["carry"], + ), + dtype=np.float64, + ) + + +def _ferro_ta_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_implied_volatility( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + option_type="call", + model="bsm", + carry=chain["carry"], + ), + dtype=np.float64, + ) + + +def _ferro_ta_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + result = ft_greeks( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + model="bsm", + carry=chain["carry"], + ) + return np.ascontiguousarray( + np.column_stack( + [ + np.asarray(result.delta, dtype=np.float64), + np.asarray(result.gamma, dtype=np.float64), + np.asarray(result.vega, dtype=np.float64), + np.asarray(result.theta, dtype=np.float64), + np.asarray(result.rho, dtype=np.float64), + ] + ), + dtype=np.float64, + ) + + +def _ferro_ta_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_black_76_price( + chain["forward"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + ), + dtype=np.float64, + ) + + +def _reference_numpy_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _bsm_price_numpy( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return _implied_vol_bisection_numpy( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + return _bsm_greeks_numpy( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _black76_price_numpy( + chain["forward"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + ) + + +def _reference_python_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + + +def _reference_python_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, price, spot, strike, rate, time_to_expiry, volatility, carry: ( + _implied_vol_bisection_scalar( + price, + spot, + strike, + rate, + time_to_expiry, + option_type="call", + carry=carry, + ) + ), + include_price=True, + ) + + +def _reference_python_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_greeks_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + + +def _reference_python_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, forward, strike, rate, time_to_expiry, volatility, carry: ( + _black76_price_scalar( + forward, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + ) + ), + include_forward=True, + ) + + +def _reprice_bsm_call_from_iv( + chain: dict[str, np.ndarray], + implied_vols: np.ndarray, +) -> np.ndarray: + rows = [ + _bsm_price_scalar( + float(spot), + float(strike), + float(rate), + float(time_to_expiry), + max(float(iv), 1e-12), + option_type="call", + carry=float(carry), + ) + for spot, strike, rate, time_to_expiry, iv, carry in zip( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + np.asarray(implied_vols, dtype=np.float64), + chain["carry"], + ) + ] + return np.asarray(rows, dtype=np.float64) + + +def _py_vollib_provider() -> Provider | None: + if importlib.util.find_spec("py_vollib") is None: + return None + + from py_vollib.black_scholes_merton import black_scholes_merton as py_vollib_bsm + from py_vollib.black_scholes_merton.implied_volatility import ( + implied_volatility as py_vollib_iv, + ) + + def _price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + [ + py_vollib_bsm( + "c", + float(s), + float(k), + float(t), + float(r), + float(vol), + float(q), + ) + for s, k, r, t, vol, q in zip( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + chain["carry"], + ) + ], + dtype=np.float64, + ) + + def _iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + [ + py_vollib_iv( + float(price), + "c", + float(s), + float(k), + float(t), + float(r), + float(q), + ) + for price, s, k, r, t, q in zip( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["carry"], + ) + ], + dtype=np.float64, + ) + + return Provider( + name="py_vollib", + kind="third_party", + note="Scalar Black-Scholes-Merton baseline from py_vollib.", + functions={ + "bsm_call_price": _price, + "bsm_call_iv": _iv, + }, + max_speed_size=1_000, + ) + + +def available_providers() -> list[Provider]: + providers = [ + Provider( + name="ferro_ta", + kind="project", + note="Rust-backed vectorized implementation.", + functions={ + "bsm_call_price": _ferro_ta_call_price, + "bsm_call_iv": _ferro_ta_call_iv, + "bsm_call_greeks": _ferro_ta_call_greeks, + "black76_call_price": _ferro_ta_black76_call_price, + }, + ), + Provider( + name="reference_numpy", + kind="reference", + note="Pure NumPy analytical formulas with vectorized IV bisection.", + functions={ + "bsm_call_price": _reference_numpy_call_price, + "bsm_call_iv": _reference_numpy_call_iv, + "bsm_call_greeks": _reference_numpy_call_greeks, + "black76_call_price": _reference_numpy_black76_call_price, + }, + ), + Provider( + name="reference_python_loop", + kind="reference", + note="Scalar math-loop analytical baseline; useful for accuracy sanity checks.", + functions={ + "bsm_call_price": _reference_python_call_price, + "bsm_call_iv": _reference_python_call_iv, + "bsm_call_greeks": _reference_python_call_greeks, + "black76_call_price": _reference_python_black76_call_price, + }, + max_speed_size=1_000, + ), + ] + optional = _py_vollib_provider() + if optional is not None: + providers.append(optional) + return providers + + +def _accuracy_metrics(actual: np.ndarray, expected: np.ndarray) -> dict[str, Any]: + actual_arr = np.asarray(actual, dtype=np.float64) + expected_arr = np.asarray(expected, dtype=np.float64) + abs_error = np.abs(actual_arr - expected_arr) + rel_error = abs_error / np.maximum(np.abs(expected_arr), 1e-12) + return { + "output_shape": list(actual_arr.shape), + "max_abs_error": round(float(np.max(abs_error)), 12), + "mean_abs_error": round(float(np.mean(abs_error)), 12), + "rmse": round( + float(np.sqrt(np.mean(np.square(actual_arr - expected_arr)))), 12 + ), + "max_rel_error": round(float(np.max(rel_error)), 12), + } + + +def _accuracy_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for case in CASES: + case_rows = [ + row for row in rows if row["case"] == case.name and "max_abs_error" in row + ] + if not case_rows: + continue + best = min(case_rows, key=lambda row: float(row["max_abs_error"])) + worst = max(case_rows, key=lambda row: float(row["max_abs_error"])) + summary.append( + { + "case": case.name, + "best_provider": best["provider"], + "best_max_abs_error": best["max_abs_error"], + "worst_provider": worst["provider"], + "worst_max_abs_error": worst["max_abs_error"], + } + ) + return summary + + +def _speed_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for case in CASES: + for size in sorted( + { + int(row["size"]) + for row in rows + if row["case"] == case.name and "median_ms" in row + } + ): + case_rows = [ + row + for row in rows + if row["case"] == case.name + and row.get("size") == size + and "median_ms" in row + ] + if not case_rows: + continue + fastest = min(case_rows, key=lambda row: float(row["median_ms"])) + ranking = [ + { + "provider": row["provider"], + "median_ms": row["median_ms"], + "contracts_per_s": row["contracts_per_s"], + } + for row in sorted(case_rows, key=lambda row: float(row["median_ms"])) + ] + summary.append( + { + "case": case.name, + "size": size, + "fastest_provider": fastest["provider"], + "fastest_median_ms": fastest["median_ms"], + "ranking": ranking, + } + ) + return summary + + +def _print_provider_inventory(providers: list[Provider]) -> None: + print("Providers:") + for provider in providers: + supported = ", ".join( + case.name for case in CASES if provider.supports(case.name) + ) + cap = ( + f" (speed cap {provider.max_speed_size})" if provider.max_speed_size else "" + ) + print(f" - {provider.name} [{provider.kind}] {provider.note}{cap}") + print(f" supported: {supported}") + print() + + +def _print_accuracy_table(rows: list[dict[str, Any]], accuracy_size: int) -> None: + print(f"Accuracy ({accuracy_size} contracts)") + print( + "IV accuracy is measured as price reconstruction error from the recovered IV." + ) + header = f"{'Case':<22} {'Provider':<24} {'Max abs err':<14} {'RMSE':<14} {'Max rel err':<14}" + print(header) + print("-" * len(header)) + for row in rows: + if "max_abs_error" not in row: + continue + print( + f"{row['label']:<22} {row['provider']:<24} " + f"{row['max_abs_error']:<14.6g} {row['rmse']:<14.6g} {row['max_rel_error']:<14.6g}" + ) + print() + + +def _print_speed_table(rows: list[dict[str, Any]]) -> None: + print(f"Speed (median of {N_RUNS} measured runs after {N_WARMUP} warmup)") + header = f"{'Case':<22} {'Size':<8} {'Provider':<24} {'Median ms':<12} {'Contracts/s':<14} {'Peak alloc':<12}" + print(header) + print("-" * len(header)) + for row in rows: + if "median_ms" not in row: + continue + peak = row.get("python_peak_allocation_bytes") + peak_label = "n/a" if peak is None else str(peak) + print( + f"{row['label']:<22} {row['size']:<8} {row['provider']:<24} " + f"{row['median_ms']:<12.4f} {row['contracts_per_s']:<14.2f} {peak_label:<12}" + ) + print() + + +def run_benchmark( + *, + sizes: list[int], + accuracy_size: int, + json_path: str | None, +) -> dict[str, Any]: + providers = available_providers() + speed_chains = { + size: _build_chain(size, seed=DEFAULT_SEED + size) for size in sizes + } + accuracy_chain = _build_chain(accuracy_size, seed=DEFAULT_SEED) + + accuracy_rows: list[dict[str, Any]] = [] + speed_rows: list[dict[str, Any]] = [] + + _print_provider_inventory(providers) + + for case in CASES: + for provider in providers: + if not provider.supports(case.name): + continue + fn = provider.functions[case.name] + actual = fn(accuracy_chain) + if case.name == "bsm_call_iv": + compared_actual = _reprice_bsm_call_from_iv(accuracy_chain, actual) + expected = np.asarray(accuracy_chain["call_price"], dtype=np.float64) + else: + compared_actual = actual + expected = np.asarray( + accuracy_chain[case.expected_key], dtype=np.float64 + ) + row = { + "case": case.name, + "label": case.label, + "provider": provider.name, + "provider_kind": provider.kind, + "sample_size": accuracy_size, + "accuracy_target": case.accuracy_target, + } + row.update(_accuracy_metrics(compared_actual, expected)) + if case.component_names is not None: + row["component_names"] = list(case.component_names) + accuracy_rows.append(row) + + _print_accuracy_table(accuracy_rows, accuracy_size) + + for case in CASES: + for size in sizes: + chain = speed_chains[size] + for provider in providers: + if not provider.supports(case.name): + continue + if ( + provider.max_speed_size is not None + and size > provider.max_speed_size + ): + continue + + fn = provider.functions[case.name] + samples_ms = _timed_runs_ms(fn, chain) + stats = _summary_stats(samples_ms) + median_ms = float(stats["median_ms"]) + contracts_per_s = _throughput_contracts_s(size, median_ms) + peak_bytes = _python_peak_bytes(fn, chain) + + speed_rows.append( + { + "case": case.name, + "label": case.label, + "size": size, + "provider": provider.name, + "provider_kind": provider.kind, + "median_ms": round(median_ms, 4), + "contracts_per_s": round(contracts_per_s, 2), + "runs_ms": [round(sample, 4) for sample in samples_ms], + "stats": stats, + "python_peak_allocation_bytes": peak_bytes, + "input_layout": { + "dtype": "float64", + "contiguous": True, + }, + } + ) + + _print_speed_table(speed_rows) + + package_names = ["numpy", "ferro-ta", "py_vollib"] + metadata = benchmark_metadata( + "benchmark_derivatives_compare", + extra={ + "dataset": { + "generator": "synthetic_option_chain", + "speed_sizes": sizes, + "accuracy_size": accuracy_size, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": DEFAULT_SEED, + "ranges": { + "spot": [80.0, 120.0], + "strike": [70.0, 130.0], + "rate": [0.0, 0.07], + "carry": [0.0, 0.03], + "time_to_expiry_years": [7.0 / 365.0, 2.0], + "volatility": [0.08, 0.65], + }, + }, + "methodology": { + "warmup_runs": N_WARMUP, + "measured_runs": N_RUNS, + "reported_metric": "median_ms", + "speed_metric": "contracts_per_second", + "accuracy_reference": ( + "Scalar analytical Black-Scholes-Merton and Black-76 formulas " + "using math.erf; IV accuracy is measured as repriced error " + "from the recovered volatility because direct volatility " + "differences can be unstable on low-vega contracts." + ), + "input_layout_notes": ( + "Benchmarks use contiguous float64 arrays. If your workload " + "passes non-contiguous arrays or mixed dtypes, benchmark that " + "path separately." + ), + "allocation_notes": ( + "python_peak_allocation_bytes is a tracemalloc snapshot of " + "Python-tracked allocations only; it does not measure native RSS." + ), + "provider_notes": ( + "reference_python_loop and py_vollib are scalar baselines and " + "are size-capped in the speed table to keep runtime reasonable." + ), + }, + "providers": [ + { + "name": provider.name, + "kind": provider.kind, + "note": provider.note, + "max_speed_size": provider.max_speed_size, + "supported_cases": [ + case.name for case in CASES if provider.supports(case.name) + ], + } + for provider in providers + ], + "packages": package_versions(*package_names), + }, + ) + + result = { + "schema_version": 1, + "command": " ".join(["python", *sys.argv]), + "n_warmup": N_WARMUP, + "n_runs": N_RUNS, + "accuracy_size": accuracy_size, + "sizes": sizes, + "metadata": metadata, + "accuracy": { + "summary": _accuracy_summary(accuracy_rows), + "results": accuracy_rows, + }, + "speed": { + "summary": _speed_summary(speed_rows), + "results": speed_rows, + }, + } + + if json_path: + output_path = Path(json_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(result, indent=2), encoding="utf-8") + print(f"Results written to {output_path}") + + return result + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Compare ferro_ta derivatives analytics against reference implementations" + ) + parser.add_argument( + "--json", + default=None, + help="Write the benchmark artifact to JSON", + ) + parser.add_argument( + "--sizes", + type=int, + nargs="+", + default=DEFAULT_SIZES, + help="Contract counts to benchmark (default: 1000 10000)", + ) + parser.add_argument( + "--accuracy-size", + type=int, + default=DEFAULT_ACCURACY_SIZE, + help="Contract count used for the accuracy pass (default: 512)", + ) + args = parser.parse_args() + run_benchmark( + sizes=args.sizes, + accuracy_size=args.accuracy_size, + json_path=args.json, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/benchmarks/bench_gpu.py b/ferro-ta-main/benchmarks/bench_gpu.py new file mode 100644 index 0000000..a46af1a --- /dev/null +++ b/ferro-ta-main/benchmarks/bench_gpu.py @@ -0,0 +1,105 @@ +""" +GPU vs CPU benchmark for ferro_ta.gpu (SMA, EMA, RSI). + +Requires: + pip install "ferro-ta[gpu]" # or pip install torch + +Run: + python benchmarks/bench_gpu.py + +The script compares wall-clock time for 1M-element arrays and prints a +summary table. If PyTorch is not installed or no GPU is found, GPU columns are skipped. +""" + +from __future__ import annotations + +import time + +import numpy as np + +# Try to import PyTorch +try: + import torch + + TORCH_AVAILABLE = True + if torch.cuda.is_available(): + DEVICE = "cuda" + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + DEVICE = "mps" + else: + DEVICE = None +except ImportError: + torch = None # type: ignore[assignment] + TORCH_AVAILABLE = False + DEVICE = None + +from ferro_ta.gpu import ema, rsi, sma + +N = 1_000_000 +REPEATS = 10 + + +def _time_fn(fn, *args, **kwargs) -> float: + """Return minimum wall time (seconds) over REPEATS calls.""" + times = [] + for _ in range(REPEATS): + t0 = time.perf_counter() + fn(*args, **kwargs) + if DEVICE == "cuda": + torch.cuda.synchronize() + elif DEVICE == "mps": + torch.mps.synchronize() + times.append(time.perf_counter() - t0) + return min(times) + + +def main() -> None: + rng = np.random.default_rng(42) + close_cpu = rng.uniform(100.0, 200.0, N) + + print(f"Array size: {N:,} elements") + print(f"Repeats: {REPEATS}") + print(f"Device: {DEVICE if DEVICE else 'CPU'}") + print() + + header = f"{'Indicator':<20} {'CPU (ms)':>10}" + if DEVICE: + header += f" {'GPU (ms)':>10} {'Speedup':>10}" + print(header) + print("-" * len(header)) + + for name, fn, kwargs in [ + ("sma(period=30)", sma, {"timeperiod": 30}), + ("ema(period=30)", ema, {"timeperiod": 30}), + ("rsi(period=14)", rsi, {"timeperiod": 14}), + ]: + cpu_time = _time_fn(fn, close_cpu, **kwargs) * 1000 # ms + + row = f"{name:<20} {cpu_time:>10.3f}" + if DEVICE: + dtype = torch.float32 if DEVICE == "mps" else torch.float64 + close_gpu = torch.tensor(close_cpu, dtype=dtype, device=DEVICE) + # Warm-up + fn(close_gpu, **kwargs) + if DEVICE == "cuda": + torch.cuda.synchronize() + elif DEVICE == "mps": + torch.mps.synchronize() + gpu_time = _time_fn(fn, close_gpu, **kwargs) * 1000 # ms + speedup = cpu_time / gpu_time + row += f" {gpu_time:>10.3f} {speedup:>10.2f}×" + print(row) + + if not TORCH_AVAILABLE: + print() + print("PyTorch not available — GPU columns skipped.") + print("Install with: pip install 'ferro_ta[gpu]'") + elif not DEVICE: + print() + print( + "PyTorch found, but no CUDA or MPS device detected — GPU columns skipped." + ) + + +if __name__ == "__main__": + main() diff --git a/ferro-ta-main/benchmarks/bench_simd.py b/ferro-ta-main/benchmarks/bench_simd.py new file mode 100644 index 0000000..d2e4896 --- /dev/null +++ b/ferro-ta-main/benchmarks/bench_simd.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata + +ROOT = Path(__file__).resolve().parents[1] + + +def _run(cmd: list[str], *, cwd: Path = ROOT) -> None: + subprocess.run(cmd, cwd=cwd, check=True) + + +def _profile_variant( + *, + label: str, + maturin_args: list[str], + price_bars: int, + iv_bars: int, + window: int, +) -> dict[str, Any]: + _run([sys.executable, "-m", "maturin", "develop", "--release", *maturin_args]) + with tempfile.TemporaryDirectory(prefix=f"ferro_ta_{label}_") as tmp_dir: + json_path = Path(tmp_dir) / "runtime_hotspots.json" + _run( + [ + sys.executable, + "benchmarks/profile_runtime_hotspots.py", + "--price-bars", + str(price_bars), + "--iv-bars", + str(iv_bars), + "--window", + str(window), + "--json", + str(json_path), + ] + ) + payload = json.loads(json_path.read_text(encoding="utf-8")) + return payload + + +def run_simd_benchmark( + *, + price_bars: int = 20_000, + iv_bars: int = 50_000, + window: int = 252, +) -> dict[str, Any]: + # `simd` is a default feature, so a pure-scalar baseline must explicitly + # opt out via --no-default-features; otherwise both builds would be + # identical and every reported speedup would collapse to 1.0. + variants = [ + ("portable_release", ["--no-default-features"]), + ("simd_release", ["--features", "simd"]), + ] + reports = { + label: _profile_variant( + label=label, + maturin_args=args, + price_bars=price_bars, + iv_bars=iv_bars, + window=window, + ) + for label, args in variants + } + + portable_rows = {row["name"]: row for row in reports["portable_release"]["results"]} + simd_rows = {row["name"]: row for row in reports["simd_release"]["results"]} + + comparison: list[dict[str, Any]] = [] + for name in sorted(portable_rows): + portable = portable_rows[name] + simd = simd_rows.get(name) + if simd is None: + continue + portable_ms = float(portable["fast_ms"]) + simd_ms = float(simd["fast_ms"]) + comparison.append( + { + "name": name, + "category": portable["category"], + "portable_ms": round(portable_ms, 4), + "simd_ms": round(simd_ms, 4), + "speedup_simd_vs_portable": round( + portable_ms / simd_ms if simd_ms > 0.0 else float("inf"), 4 + ), + } + ) + + comparison.sort( + key=lambda row: float(row["speedup_simd_vs_portable"]), reverse=True + ) + + # Restore the default portable editable build so the workspace ends in the + # distributable configuration. + _run([sys.executable, "-m", "maturin", "develop", "--release"]) + + return { + "metadata": benchmark_metadata( + "simd", + extra={ + "dataset": { + "price_bars": price_bars, + "iv_bars": iv_bars, + "window": window, + }, + "variants": [label for label, _ in variants], + }, + ), + "results": comparison, + "reports": reports, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Benchmark portable vs SIMD-enabled ferro-ta builds." + ) + parser.add_argument("--price-bars", type=int, default=20_000) + parser.add_argument("--iv-bars", type=int, default=50_000) + parser.add_argument("--window", type=int, default=252) + parser.add_argument("--json", dest="json_path") + args = parser.parse_args() + + payload = run_simd_benchmark( + price_bars=args.price_bars, + iv_bars=args.iv_bars, + window=args.window, + ) + + print(f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}") + print("-" * 64) + for row in payload["results"]: + print( + f"{row['name']:<20} {row['portable_ms']:14.4f} " + f"{row['simd_ms']:12.4f} {row['speedup_simd_vs_portable']:14.2f}x" + ) + + if args.json_path: + path = Path(args.json_path) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nWrote JSON results to {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/benchmarks/bench_streaming.py b/ferro-ta-main/benchmarks/bench_streaming.py new file mode 100644 index 0000000..afc78d5 --- /dev/null +++ b/ferro-ta-main/benchmarks/bench_streaming.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import argparse +import json +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import numpy as np + +import ferro_ta as ft + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata + + +def _time_min(fn: Callable[[], object], rounds: int = 5) -> float: + fn() + samples: list[float] = [] + for _ in range(rounds): + t0 = time.perf_counter() + fn() + samples.append(time.perf_counter() - t0) + return min(samples) + + +def _stream_close(close: np.ndarray, factory: Callable[[], Any]) -> float: + streamer = factory() + last = np.nan + for value in close: + last = streamer.update(float(value)) + return float(last) if not np.isnan(last) else np.nan + + +def _stream_hlc( + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, + factory: Callable[[], Any], +) -> float: + streamer = factory() + last = np.nan + for high_value, low_value, close_value in zip(high, low, close): + last = streamer.update(float(high_value), float(low_value), float(close_value)) + return float(last) if not np.isnan(last) else np.nan + + +def _stream_hlcv( + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, + volume: np.ndarray, + factory: Callable[[], Any], +) -> float: + streamer = factory() + last = np.nan + for high_value, low_value, close_value, volume_value in zip( + high, low, close, volume + ): + last = streamer.update( + float(high_value), + float(low_value), + float(close_value), + float(volume_value), + ) + return float(last) if not np.isnan(last) else np.nan + + +def run_streaming_benchmark( + *, + n_bars: int = 100_000, + seed: int = 2026, +) -> dict[str, Any]: + rng = np.random.default_rng(seed) + close = 100.0 + np.cumsum(rng.normal(0.0, 1.0, n_bars)).astype(np.float64) + high = close + rng.uniform(0.1, 2.0, n_bars) + low = close - rng.uniform(0.1, 2.0, n_bars) + volume = rng.uniform(1_000.0, 100_000.0, n_bars) + + cases = [ + ( + "StreamingSMA", + "close", + lambda: _stream_close(close, lambda: ft.StreamingSMA(period=20)), + lambda: ft.SMA(close, timeperiod=20), + ), + ( + "StreamingEMA", + "close", + lambda: _stream_close(close, lambda: ft.StreamingEMA(period=20)), + lambda: ft.EMA(close, timeperiod=20), + ), + ( + "StreamingRSI", + "close", + lambda: _stream_close(close, lambda: ft.StreamingRSI(period=14)), + lambda: ft.RSI(close, timeperiod=14), + ), + ( + "StreamingATR", + "hlc", + lambda: _stream_hlc( + high, + low, + close, + lambda: ft.StreamingATR(period=14), + ), + lambda: ft.ATR(high, low, close, timeperiod=14), + ), + ( + "StreamingVWAP", + "hlcv", + lambda: _stream_hlcv( + high, + low, + close, + volume, + lambda: ft.StreamingVWAP(), + ), + lambda: ft.VWAP(high, low, close, volume), + ), + ] + + rows: list[dict[str, Any]] = [] + for name, input_kind, stream_fn, batch_fn in cases: + stream_s = _time_min(stream_fn) + batch_s = _time_min(batch_fn) + rows.append( + { + "indicator": name, + "inputs": input_kind, + "stream_total_ms": round(stream_s * 1000.0, 4), + "batch_total_ms": round(batch_s * 1000.0, 4), + "stream_ns_per_update": round(stream_s * 1e9 / n_bars, 2), + "batch_ns_per_bar": round(batch_s * 1e9 / n_bars, 2), + "updates_per_second": round(n_bars / stream_s, 2), + "stream_over_batch_ratio": round(stream_s / batch_s, 4), + } + ) + + return { + "metadata": benchmark_metadata( + "streaming", + extra={ + "dataset": { + "n_bars": n_bars, + "seed": seed, + } + }, + ), + "results": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Benchmark streaming indicator execution." + ) + parser.add_argument("--bars", type=int, default=100_000) + parser.add_argument("--seed", type=int, default=2026) + parser.add_argument("--json", dest="json_path") + args = parser.parse_args() + + payload = run_streaming_benchmark(n_bars=args.bars, seed=args.seed) + + dataset = payload["metadata"]["dataset"] + print(f"Streaming Benchmark: {dataset['n_bars']} bars") + print("-" * 86) + print( + f"{'Indicator':<16} {'Stream (ms)':>12} {'Batch (ms)':>12} " + f"{'ns/update':>12} {'upd/s':>12} {'ratio':>10}" + ) + print("-" * 86) + for row in payload["results"]: + print( + f"{row['indicator']:<16} {row['stream_total_ms']:12.2f} " + f"{row['batch_total_ms']:12.2f} {row['stream_ns_per_update']:12.2f} " + f"{row['updates_per_second']:12.1f} {row['stream_over_batch_ratio']:10.2f}" + ) + + if args.json_path: + path = Path(args.json_path) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nWrote JSON results to {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/benchmarks/bench_vs_talib.py b/ferro-ta-main/benchmarks/bench_vs_talib.py new file mode 100644 index 0000000..3dae1d5 --- /dev/null +++ b/ferro-ta-main/benchmarks/bench_vs_talib.py @@ -0,0 +1,481 @@ +""" +ferro_ta vs TA-Lib speed comparison. + +Measures throughput (M bars/s) for both libraries on the same synthetic data +and parameters. The output is intentionally evidence-heavy: + +- median timings +- per-run timing samples +- variability stats +- Python-tracked peak allocation snapshots +- machine, runtime, and build metadata + +This is meant to support a narrow claim: ferro-ta is often faster on selected +indicators, not universally faster. +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +import time +import tracemalloc +from typing import Any + +import numpy as np + +try: + import talib # noqa: F401 + + TALIB_AVAILABLE = True +except ImportError: + TALIB_AVAILABLE = False + talib = None # type: ignore[assignment] + +import ferro_ta + +try: + from benchmarks.metadata import benchmark_metadata, package_versions +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata, package_versions + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +N_WARMUP = 1 +N_RUNS = 7 +DEFAULT_SIZES = [10_000, 100_000, 1_000_000] +TIE_EPSILON = 0.05 + +_rng = np.random.default_rng(42) + + +def _median(values: list[float]) -> float: + ordered = sorted(values) + mid = len(ordered) // 2 + if len(ordered) % 2: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 + + +def _summary_stats(samples_ms: list[float]) -> dict[str, float]: + if not samples_ms: + return { + "median_ms": 0.0, + "mean_ms": 0.0, + "min_ms": 0.0, + "max_ms": 0.0, + "stddev_ms": 0.0, + "cv_pct": 0.0, + } + + mean_ms = sum(samples_ms) / len(samples_ms) + variance = ( + sum((sample - mean_ms) ** 2 for sample in samples_ms) / (len(samples_ms) - 1) + if len(samples_ms) > 1 + else 0.0 + ) + stddev_ms = math.sqrt(variance) + cv_pct = (stddev_ms / mean_ms * 100.0) if mean_ms else 0.0 + return { + "median_ms": round(_median(samples_ms), 4), + "mean_ms": round(mean_ms, 4), + "min_ms": round(min(samples_ms), 4), + "max_ms": round(max(samples_ms), 4), + "stddev_ms": round(stddev_ms, 4), + "cv_pct": round(cv_pct, 3), + } + + +def _outcome(speedup: float) -> str: + if speedup > 1.0 + TIE_EPSILON: + return "ferro_ta_win" + if speedup < 1.0 - TIE_EPSILON: + return "talib_win" + return "tie" + + +def _summary_for_size(results: list[dict[str, Any]], size: int) -> dict[str, Any]: + rows = [row for row in results if row.get("size") == size and "speedup" in row] + if not rows: + return {"size": size, "rows": 0} + + speedups = [float(row["speedup"]) for row in rows] + wins = sum(1 for row in rows if row.get("outcome") == "ferro_ta_win") + ties = sum(1 for row in rows if row.get("outcome") == "tie") + losses = sum(1 for row in rows if row.get("outcome") == "talib_win") + return { + "size": size, + "rows": len(rows), + "wins": wins, + "ties": ties, + "losses": losses, + "win_rate": round(wins / len(rows), 4), + "non_loss_rate": round((wins + ties) / len(rows), 4), + "median_speedup": round(_median(speedups), 4), + "min_speedup": round(min(speedups), 4), + "max_speedup": round(max(speedups), 4), + "talib_wins_or_ties": [ + row["indicator"] + for row in rows + if row.get("outcome") in {"talib_win", "tie"} + ], + } + + +def _synthetic_ohlcv( + n: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + # Generate OHLCV so that ta crate DataItem constraints hold: low >= 0, + # volume >= 0, and low <= open, close <= high, high >= open. + close = 100.0 + np.cumsum(_rng.standard_normal(n) * 0.5) + open_ = close + _rng.standard_normal(n) * 0.2 + high = np.maximum(open_, close) + np.abs(_rng.standard_normal(n) * 0.3) + low = np.minimum(open_, close) - np.abs(_rng.standard_normal(n) * 0.3) + high = np.maximum(high, low) + low = np.maximum(low, 0.0) + high = np.maximum(high, low) + open_ = np.clip(open_, low, high) + close = np.clip(close, low, high) + volume = np.abs(_rng.standard_normal(n) * 1_000_000) + 500_000 + return open_, high, low, close, volume + + +def _timed_runs_ms(fn, *args, **kwargs) -> list[float]: + for _ in range(N_WARMUP): + fn(*args, **kwargs) + + samples_ms: list[float] = [] + for _ in range(N_RUNS): + t0 = time.perf_counter() + fn(*args, **kwargs) + samples_ms.append((time.perf_counter() - t0) * 1000.0) + return samples_ms + + +def _python_peak_bytes(fn, *args, **kwargs) -> int | None: + try: + tracemalloc.start() + tracemalloc.reset_peak() + fn(*args, **kwargs) + _, peak = tracemalloc.get_traced_memory() + return int(peak) + except Exception: + return None + finally: + tracemalloc.stop() + + +def _throughput_m_bars_s(size: int, median_ms: float) -> float: + if median_ms <= 0: + return 0.0 + return (size / 1e6) / (median_ms / 1000.0) + + +# --------------------------------------------------------------------------- +# Benchmarked callables +# --------------------------------------------------------------------------- + + +def _run_ft_sma(o, h, l, c, v, n): + return ferro_ta.SMA(c[:n], timeperiod=14) + + +def _run_ta_sma(o, h, l, c, v, n): + return talib.SMA(c[:n], timeperiod=14) + + +def _run_ft_ema(o, h, l, c, v, n): + return ferro_ta.EMA(c[:n], timeperiod=14) + + +def _run_ta_ema(o, h, l, c, v, n): + return talib.EMA(c[:n], timeperiod=14) + + +def _run_ft_rsi(o, h, l, c, v, n): + return ferro_ta.RSI(c[:n], timeperiod=14) + + +def _run_ta_rsi(o, h, l, c, v, n): + return talib.RSI(c[:n], timeperiod=14) + + +def _run_ft_bbands(o, h, l, c, v, n): + return ferro_ta.BBANDS(c[:n], timeperiod=20, nbdevup=2.0, nbdevdn=2.0) + + +def _run_ta_bbands(o, h, l, c, v, n): + return talib.BBANDS(c[:n], timeperiod=20, nbdevup=2.0, nbdevdn=2.0) + + +def _run_ft_macd(o, h, l, c, v, n): + return ferro_ta.MACD(c[:n], fastperiod=12, slowperiod=26, signalperiod=9) + + +def _run_ta_macd(o, h, l, c, v, n): + return talib.MACD(c[:n], fastperiod=12, slowperiod=26, signalperiod=9) + + +def _run_ft_atr(o, h, l, c, v, n): + return ferro_ta.ATR(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ta_atr(o, h, l, c, v, n): + return talib.ATR(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ft_stoch(o, h, l, c, v, n): + return ferro_ta.STOCH(h[:n], l[:n], c[:n]) + + +def _run_ta_stoch(o, h, l, c, v, n): + return talib.STOCH(h[:n], l[:n], c[:n]) + + +def _run_ft_adx(o, h, l, c, v, n): + return ferro_ta.ADX(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ta_adx(o, h, l, c, v, n): + return talib.ADX(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ft_cci(o, h, l, c, v, n): + return ferro_ta.CCI(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ta_cci(o, h, l, c, v, n): + return talib.CCI(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ft_obv(o, h, l, c, v, n): + return ferro_ta.OBV(c[:n], v[:n]) + + +def _run_ta_obv(o, h, l, c, v, n): + return talib.OBV(c[:n], v[:n]) + + +def _run_ft_mfi(o, h, l, c, v, n): + return ferro_ta.MFI(h[:n], l[:n], c[:n], v[:n], timeperiod=14) + + +def _run_ta_mfi(o, h, l, c, v, n): + return talib.MFI(h[:n], l[:n], c[:n], v[:n], timeperiod=14) + + +def _run_ft_wma(o, h, l, c, v, n): + return ferro_ta.WMA(c[:n], timeperiod=14) + + +def _run_ta_wma(o, h, l, c, v, n): + return talib.WMA(c[:n], timeperiod=14) + + +COMPARISON_CASES = [ + ("SMA", _run_ft_sma, _run_ta_sma), + ("EMA", _run_ft_ema, _run_ta_ema), + ("RSI", _run_ft_rsi, _run_ta_rsi), + ("BBANDS", _run_ft_bbands, _run_ta_bbands), + ("MACD", _run_ft_macd, _run_ta_macd), + ("ATR", _run_ft_atr, _run_ta_atr), + ("STOCH", _run_ft_stoch, _run_ta_stoch), + ("ADX", _run_ft_adx, _run_ta_adx), + ("CCI", _run_ft_cci, _run_ta_cci), + ("OBV", _run_ft_obv, _run_ta_obv), + ("MFI", _run_ft_mfi, _run_ta_mfi), + ("WMA", _run_ft_wma, _run_ta_wma), +] + +SKIP_1M_FOR = {"STOCH", "ADX"} + + +def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, Any]]: + max_size = max(sizes) + open_, high, low, close, volume = _synthetic_ohlcv(max_size) + results: list[dict[str, Any]] = [] + + col_label = 10 + col_size = 10 + col_ft_ms = 12 + col_ta_ms = 12 + col_speedup = 10 + col_ft_m = 12 + col_ta_m = 12 + + if not TALIB_AVAILABLE: + print("Note: ta-lib not installed. Reporting ferro_ta timings only.") + print( + "Install with: pip install ta-lib (or conda install ta-lib) for comparison.\n" + ) + + print( + f"\nferro_ta vs TA-Lib — median of {N_RUNS} measured runs after {N_WARMUP} warmup" + ) + print(f"Sizes: {sizes}") + print() + + header = ( + f"{'Indicator':<{col_label}} {'Size':<{col_size}} " + f"{'ferro_ta(ms)':<{col_ft_ms}} {'TA-Lib(ms)':<{col_ta_ms}} " + f"{'Speedup':<{col_speedup}} {'ferro_ta(M/s)':<{col_ft_m}} {'TA-Lib(M/s)':<{col_ta_m}}" + ) + print(header) + print("-" * len(header)) + + for name, ft_run, ta_run in COMPARISON_CASES: + for size in sizes: + if size == 1_000_000 and name in SKIP_1M_FOR: + continue + + ft_samples_ms = _timed_runs_ms( + ft_run, open_, high, low, close, volume, size + ) + ft_stats = _summary_stats(ft_samples_ms) + ft_median_ms = float(ft_stats["median_ms"]) + ft_m_bars_s = _throughput_m_bars_s(size, ft_median_ms) + ft_peak_bytes = _python_peak_bytes( + ft_run, open_, high, low, close, volume, size + ) + + row: dict[str, Any] = { + "indicator": name, + "size": size, + "input_layout": { + "dtype": "float64", + "contiguous": True, + }, + "ferro_ta_ms": round(ft_median_ms, 4), + "ferro_ta_m_bars_s": round(ft_m_bars_s, 2), + "ferro_ta_runs_ms": [round(sample, 4) for sample in ft_samples_ms], + "ferro_ta_stats": ft_stats, + "python_peak_allocation_bytes": { + "ferro_ta": ft_peak_bytes, + }, + } + + if TALIB_AVAILABLE: + ta_samples_ms = _timed_runs_ms( + ta_run, open_, high, low, close, volume, size + ) + ta_stats = _summary_stats(ta_samples_ms) + ta_median_ms = float(ta_stats["median_ms"]) + ta_m_bars_s = _throughput_m_bars_s(size, ta_median_ms) + speedup = ( + ta_median_ms / ft_median_ms if ft_median_ms > 0 else float("inf") + ) + outcome = _outcome(speedup) + ta_peak_bytes = _python_peak_bytes( + ta_run, open_, high, low, close, volume, size + ) + + print( + f"{name:<{col_label}} {size:<{col_size}} " + f"{ft_median_ms:<{col_ft_ms}.3f} {ta_median_ms:<{col_ta_ms}.3f} " + f"{speedup:<{col_speedup}.2f}x {ft_m_bars_s:<{col_ft_m}.1f} {ta_m_bars_s:<{col_ta_m}.1f}" + ) + + row.update( + { + "talib_ms": round(ta_median_ms, 4), + "talib_m_bars_s": round(ta_m_bars_s, 2), + "talib_runs_ms": [round(sample, 4) for sample in ta_samples_ms], + "talib_stats": ta_stats, + "speedup": round(speedup, 4), + "outcome": outcome, + } + ) + row["python_peak_allocation_bytes"]["talib"] = ta_peak_bytes + else: + print( + f"{name:<{col_label}} {size:<{col_size}} " + f"{ft_median_ms:<{col_ft_ms}.3f} {'N/A':<{col_ta_ms}} " + f"{'N/A':<{col_speedup}} {ft_m_bars_s:<{col_ft_m}.1f} {'N/A':<{col_ta_m}}" + ) + + results.append(row) + + print() + if TALIB_AVAILABLE and results: + wins = sum(1 for row in results if row.get("outcome") == "ferro_ta_win") + total = len([row for row in results if "speedup" in row]) + print(f"Summary: ferro_ta ahead outside the tie band on {wins}/{total} rows.") + print() + + if json_path: + metadata = benchmark_metadata( + "benchmark_vs_talib", + extra={ + "dataset": { + "generator": "synthetic_ohlcv", + "sizes": sizes, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": 42, + }, + "methodology": { + "warmup_runs": N_WARMUP, + "measured_runs": N_RUNS, + "reported_metric": "median_ms", + "speedup_definition": "talib_median_ms / ferro_ta_median_ms", + "tie_band": f"{1.0 - TIE_EPSILON:.2f} to {1.0 + TIE_EPSILON:.2f}", + "input_layout_notes": ( + "Benchmarks use contiguous float64 arrays. If your workload " + "passes non-contiguous arrays or other dtypes, benchmark that " + "separately because wrapper overhead can dominate." + ), + "allocation_notes": ( + "python_peak_allocation_bytes is a tracemalloc snapshot of " + "Python-tracked allocations only; it is not a full native RSS " + "or allocator profile." + ), + }, + "packages": package_versions("numpy", "ferro-ta", "TA-Lib"), + }, + ) + out = { + "schema_version": 2, + "command": " ".join(["python", *sys.argv]), + "n_warmup": N_WARMUP, + "n_runs": N_RUNS, + "sizes": sizes, + "talib_available": TALIB_AVAILABLE, + "runtime": metadata["runtime"], + "git": metadata["git"], + "metadata": metadata, + "summary": { + "total_rows": len(results), + "by_size": [_summary_for_size(results, size) for size in sizes], + }, + "results": results, + } + if not TALIB_AVAILABLE: + out["note"] = "ferro_ta only; ta-lib not installed" + with open(json_path, "w", encoding="utf-8") as handle: + json.dump(out, handle, indent=2) + print(f"Results written to {json_path}") + + return results + + +def main() -> int: + parser = argparse.ArgumentParser(description="ferro_ta vs TA-Lib speed comparison") + parser.add_argument("--json", default=None, help="Write results to JSON file") + parser.add_argument( + "--sizes", + type=int, + nargs="+", + default=DEFAULT_SIZES, + help="Bar counts to benchmark (default: 10000 100000 1000000)", + ) + args = parser.parse_args() + run_comparison(args.sizes, args.json) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ferro-ta-main/benchmarks/benchmark_table.py b/ferro-ta-main/benchmarks/benchmark_table.py new file mode 100644 index 0000000..c7a06bf --- /dev/null +++ b/ferro-ta-main/benchmarks/benchmark_table.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Generate the Speed Comparison markdown table from benchmarks/results.json. + +Requires results from the full suite: + pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v + +Reads results.json and prints a markdown table: all indicators × all libraries. +Unsupported (indicator, library) pairs show N/A. Supported pairs missing benchmark +data show ERR (indicating the benchmark run was incomplete or failed). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Ensure project root is on path when run as script +_root = Path(__file__).resolve().parent.parent +if _root not in (Path(p).resolve() for p in sys.path): + sys.path.insert(0, str(_root)) + +from benchmarks.wrapper_registry import ( + INDICATOR_CATEGORIES, + is_supported, +) +from benchmarks.wrapper_registry import ( + LIBRARY_NAMES as LIBS, +) + + +def _all_indicators() -> list[str]: + """All indicators in category order (matches test_speed parametrization).""" + return [ind for cat in INDICATOR_CATEGORIES for ind in INDICATOR_CATEGORIES[cat]] + + +def main(): + p = Path(__file__).parent / "results.json" + if not p.exists(): + print( + "Run: pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v", + file=sys.stderr, + ) + sys.exit(1) + raw = p.read_text().strip() + if not raw: + print( + "results.json is empty. Run the full benchmark suite first.", + file=sys.stderr, + ) + sys.exit(1) + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + print(f"Invalid JSON in results.json: {e}", file=sys.stderr) + sys.exit(1) + benchmarks = data.get("benchmarks", []) + + # Collect test_speed[Category/Indicator/library] -> median µs + table: dict[str, dict[str, float]] = {} + for b in benchmarks: + name = b.get("name") or "" + if "test_speed[" not in name: + continue + params = b.get("params") or {} + ind = params.get("indicator") + lib = params.get("library") + if not ind or not lib or lib not in LIBS: + continue + median_sec = (b.get("stats") or {}).get("median") + if median_sec is None: + continue + if ind not in table: + table[ind] = {} + table[ind][lib] = median_sec * 1e6 # to µs + + all_indicators = _all_indicators() + if not all_indicators: + print("No indicators from INDICATOR_CATEGORIES.", file=sys.stderr) + sys.exit(1) + + # Header: Indicator | ferro_ta | talib | ... + lib_header = " | ".join(LIBS) + print(f"| Indicator | {lib_header} |") + print("|-----------|" + "|".join(["--------:" for _ in LIBS]) + "|") + + for ind in all_indicators: + row = table.get(ind, {}) + cells = [] + for lib in LIBS: + if lib in row: + cells.append(str(round(row[lib]))) + elif not is_supported(lib, ind): + cells.append("N/A") + else: + cells.append("ERR") + print(f"| {ind} | {' | '.join(cells)} |") + + print() + print( + "(Median time in µs, lower is better. N/A = unsupported pair. " + "ERR = supported pair missing benchmark data. Source: results.json from full test_speed run.)" + ) + + +if __name__ == "__main__": + main() diff --git a/ferro-ta-main/benchmarks/check_hotspot_regression.py b/ferro-ta-main/benchmarks/check_hotspot_regression.py new file mode 100644 index 0000000..495fad8 --- /dev/null +++ b/ferro-ta-main/benchmarks/check_hotspot_regression.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Validate hotspot benchmark JSON against conservative speedup floors. + +This gate is intentionally lightweight: it checks that the optimized paths +remain faster than their bundled reference implementations and that all +expected cases were present in the report. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def _parse_threshold_items(items: list[str]) -> dict[str, float]: + thresholds: dict[str, float] = {} + for item in items: + if "=" not in item: + raise ValueError(f"Invalid threshold '{item}', expected NAME=VALUE") + name, value_s = item.split("=", 1) + thresholds[name] = float(value_s) + return thresholds + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check hotspot benchmark JSON against regression thresholds." + ) + parser.add_argument( + "--input", + default="runtime_hotspots.json", + help="Path to JSON produced by benchmarks/profile_runtime_hotspots.py", + ) + parser.add_argument( + "--min-speedup", + action="append", + default=[ + "CORREL=2.0", + "BETA=2.0", + "LINEARREG=2.0", + "TSF=2.0", + "iv_rank=1.1", + "iv_percentile=1.1", + "iv_zscore=1.05", + "compute_many_close=0.85", + "feature_matrix=0.40", + ], + help="Required minimum speedup per named case, e.g. CORREL=5.0 (repeatable)", + ) + parser.add_argument( + "--min-cases", + type=int, + default=9, + help="Minimum number of benchmark rows expected in the report", + ) + args = parser.parse_args() + + path = Path(args.input) + if not path.exists(): + print(f"ERROR: hotspot benchmark file not found: {path}") + return 1 + + payload = json.loads(path.read_text(encoding="utf-8")) + rows = payload.get("results", []) + if len(rows) < args.min_cases: + print( + f"ERROR: hotspot report contains {len(rows)} rows, expected at least {args.min_cases}" + ) + return 1 + + thresholds = _parse_threshold_items(args.min_speedup) + rows_by_name = {str(row.get("name")): row for row in rows} + failures: list[str] = [] + + for name, floor in thresholds.items(): + row = rows_by_name.get(name) + if row is None: + failures.append(f"missing row for {name}") + continue + + speedup = float(row.get("speedup_vs_reference", 0.0)) + fast_ms = float(row.get("fast_ms", 0.0)) + reference_ms = float(row.get("reference_ms", 0.0)) + print( + f"{name}: fast_ms={fast_ms:.4f}, reference_ms={reference_ms:.4f}, " + f"speedup={speedup:.4f}" + ) + + if fast_ms <= 0.0 or reference_ms <= 0.0: + failures.append(f"{name} has non-positive timing values") + if speedup < floor: + failures.append(f"{name} speedup {speedup:.4f} < floor {floor:.4f}") + + if failures: + print("FAILED hotspot regression policy:") + for failure in failures: + print(f" - {failure}") + return 1 + + print("PASS hotspot regression policy.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/benchmarks/check_vs_talib_regression.py b/ferro-ta-main/benchmarks/check_vs_talib_regression.py new file mode 100644 index 0000000..5077b72 --- /dev/null +++ b/ferro-ta-main/benchmarks/check_vs_talib_regression.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Validate benchmark-vs-TA-Lib results against guardrail thresholds. + +This is intentionally conservative: it catches severe regressions and incomplete +benchmark outputs, without overfitting to one machine. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def _parse_threshold_items(items: list[str]) -> dict[int, float]: + thresholds: dict[int, float] = {} + for item in items: + if "=" not in item: + raise ValueError(f"Invalid threshold '{item}', expected SIZE=VALUE") + size_s, value_s = item.split("=", 1) + thresholds[int(size_s)] = float(value_s) + return thresholds + + +def _percentile(values: list[float], q: float) -> float: + """Return the q percentile using linear interpolation.""" + if not values: + raise ValueError("Cannot compute percentile of empty sequence") + if q <= 0: + return min(values) + if q >= 100: + return max(values) + + values = sorted(values) + rank = (len(values) - 1) * (q / 100.0) + lower = int(rank) + upper = min(lower + 1, len(values) - 1) + weight = rank - lower + return values[lower] * (1.0 - weight) + values[upper] * weight + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check TA-Lib benchmark JSON against regression thresholds." + ) + parser.add_argument( + "--input", + default="benchmark_vs_talib.json", + help="Path to benchmark JSON produced by benchmarks/bench_vs_talib.py", + ) + parser.add_argument( + "--min-rows", + type=int, + default=10, + help="Minimum benchmark rows required per size", + ) + parser.add_argument( + "--median-floor", + action="append", + default=["10000=0.35", "100000=0.35"], + help="Required minimum median speedup per size, e.g. 100000=0.5 (repeatable)", + ) + parser.add_argument( + "--min-speedup-floor", + action="append", + default=["10000=0.10", "100000=0.10"], + help="Hard minimum per-row speedup floor per size, e.g. 100000=0.1 (repeatable)", + ) + parser.add_argument( + "--tail-percentile", + type=float, + default=10.0, + help="Tail percentile used for distribution-based slowdown checks (default: 10)", + ) + parser.add_argument( + "--tail-speedup-floor", + action="append", + default=["10000=0.20", "100000=0.20"], + help="Required minimum tail percentile speedup per size, e.g. 100000=0.2 (repeatable)", + ) + args = parser.parse_args() + + path = Path(args.input) + if not path.exists(): + print(f"ERROR: benchmark file not found: {path}") + return 1 + + data = json.loads(path.read_text(encoding="utf-8")) + if not data.get("talib_available", False): + print( + "ERROR: TA-Lib was not available; cannot enforce TA-Lib regression policy." + ) + return 1 + + summary_by_size = { + int(entry.get("size")): entry + for entry in data.get("summary", {}).get("by_size", []) + if entry.get("size") is not None + } + results_by_size: dict[int, list[dict[str, object]]] = {} + for row in data.get("results", []): + if "speedup" not in row or row.get("size") is None: + continue + size = int(row["size"]) + results_by_size.setdefault(size, []).append(row) + + median_floor = _parse_threshold_items(args.median_floor) + min_speedup_floor = _parse_threshold_items(args.min_speedup_floor) + tail_speedup_floor = _parse_threshold_items(args.tail_speedup_floor) + required_sizes = sorted( + set(median_floor) | set(min_speedup_floor) | set(tail_speedup_floor) + ) + + failures: list[str] = [] + for size in required_sizes: + entry = summary_by_size.get(size) + if entry is None: + failures.append(f"missing summary for size={size}") + continue + rows_for_size = results_by_size.get(size, []) + if not rows_for_size: + failures.append(f"missing detailed rows for size={size}") + continue + + rows = int(entry.get("rows", 0)) + med = float(entry.get("median_speedup", 0.0)) + min_s = float(entry.get("min_speedup", 0.0)) + speedups = [float(row["speedup"]) for row in rows_for_size] + tail_s = _percentile(speedups, args.tail_percentile) + print( + "size=" + f"{size}: rows={rows}, median_speedup={med:.4f}, " + f"p{args.tail_percentile:g}_speedup={tail_s:.4f}, min_speedup={min_s:.4f}" + ) + + if rows < args.min_rows: + failures.append(f"size={size} rows {rows} < min_rows {args.min_rows}") + if med < median_floor.get(size, float("-inf")): + failures.append( + f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}" + ) + if tail_s < tail_speedup_floor.get(size, float("-inf")): + failures.append( + "size=" + f"{size} p{args.tail_percentile:g}_speedup {tail_s:.4f} " + f"< floor {tail_speedup_floor[size]:.4f}" + ) + if min_s < min_speedup_floor.get(size, float("-inf")): + failures.append( + f"size={size} min_speedup {min_s:.4f} < floor {min_speedup_floor[size]:.4f}" + ) + + if failures: + print("FAILED benchmark regression policy:") + for failure in failures: + print(f" - {failure}") + return 1 + + print("PASS benchmark regression policy.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/benchmarks/data_generator.py b/ferro-ta-main/benchmarks/data_generator.py new file mode 100644 index 0000000..949bb58 --- /dev/null +++ b/ferro-ta-main/benchmarks/data_generator.py @@ -0,0 +1,68 @@ +""" +Benchmark data generator for cross-library comparison. + +Produces C-contiguous float64 NumPy arrays that work correctly with all +six libraries (ferro-ta, TA-Lib, pandas-ta, ta, Tulipy, finta). +Critical: every array is np.ascontiguousarray(..., dtype=np.float64) to +prevent memory segmentation faults in C-extension libraries. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +_RNG = np.random.default_rng(42) + + +def generate_ohlcv(size: int = 10_000) -> dict[str, np.ndarray]: + """Return a dict of C-contiguous float64 OHLCV arrays. + + Uses a geometric Brownian motion walk so values are realistic (no + negatives, bounded intraday spread). Every array satisfies: + high >= close >= low > 0 + open > 0 + volume > 0 + """ + # Geometric random walk for close + returns = _RNG.normal(0.0002, 0.01, size) + close = 100.0 * np.exp(np.cumsum(returns)) + + noise_hi = np.abs(_RNG.normal(0, 0.005, size)) * close + noise_lo = np.abs(_RNG.normal(0, 0.005, size)) * close + + high = close + noise_hi + low = np.maximum(close - noise_lo, 0.01) # never negative + open_ = low + _RNG.random(size) * (high - low) + volume = _RNG.uniform(1e5, 1e7, size) + + def _c(arr: np.ndarray) -> np.ndarray: + return np.ascontiguousarray(arr, dtype=np.float64) + + return { + "open": _c(open_), + "high": _c(high), + "low": _c(low), + "close": _c(close), + "volume": _c(volume), + } + + +def get_pandas_ohlcv(data: dict[str, np.ndarray]) -> pd.DataFrame: + """Convert an OHLCV dict to a DataFrame with a DatetimeIndex. + + pandas-ta and finta both require a datetime-indexed DataFrame with + lowercase column names (open/high/low/close/volume). + """ + idx = pd.date_range("2015-01-01", periods=len(data["close"]), freq="D") + return pd.DataFrame(data, index=idx) + + +# Pre-built datasets at several scales so benchmarks can import them directly +SMALL = generate_ohlcv(1_000) +MEDIUM = generate_ohlcv(10_000) +LARGE = generate_ohlcv(100_000) + +SMALL_DF = get_pandas_ohlcv(SMALL) +MEDIUM_DF = get_pandas_ohlcv(MEDIUM) +LARGE_DF = get_pandas_ohlcv(LARGE) diff --git a/ferro-ta-main/benchmarks/fixtures/canonical_ohlcv.npz b/ferro-ta-main/benchmarks/fixtures/canonical_ohlcv.npz new file mode 100644 index 0000000000000000000000000000000000000000..0eee506a5eec913077a7f3592587b16ba55e22e2 GIT binary patch literal 75586 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

HDyC>`I0Mh zOxXBG-@I*$2ODkoJQiy5*kGSY+Q&9$Ly$PSk2Qsj!L_k-UAC|>^B{YOM%E9l`stqd zorR-w7vH0Iv#@^Je!hMb8zc7HPM?fp<4g$WUv@Sd+vn;GjxA*4>3q+urk!jE{|k(Y zA>Xy#-_$0M^#;jsmRi6@5jUY#g`D^7b!z;F$!utDN_ceMhmBFens+nTvT;l&qijTv z4Q9EzhscwS)Pj!TAX4u*A0?TTDmI+t7d&XX%SKk9*BOO!;=e{YFZHYBe;JWDbPpR( zv>vDY&0=Hf#*J}m1dnIe_8(WMXCpjiUh|LBWc{QAr#wR0SY`Fh+zo8BpPPEnx}OCD zwnA}n0}D?BB0n7tW5HcqQ{-`&g;$3uFV04>P~#Avd6vP#f7LUsj~`$Wz5Tnx{0*?6-l|M@0uHX2P$*L5kfk-R@oBlZIe^*Lq1`-fTB zWv;HOLe7t$bGk4inT77r-R{X(SfJ-L47?@hy-W6bZTFQ0l~SkO6RxqKZMb2%+A|jB z9b>h5pCJ1O6QjPrCc5ELNlHN<3##{@iS;#ze`V(Vx*yC&_vWp6YR}o|De^UsCG~g4 zu-VgIQZI#CUsL!M1RoFTigjf$7V1wmi z{5Xl!<4)&?*XFj8`du{F@q!e=^^zNQSp@%zGri64uH%6F;DwJa(FgB4N_H@b9w^AY zFgZGhjm#asmi>O@939`>EL}EC^7cl>kh=cPFz`p1J{u8LzVD*fvC*HC^?1rWqAN~> zb=(YOV}Yre$!D^!mpPmhM)32;bo#RMhuA2-EAlNNdZ_J;mq_1@@CsYq$7F!`;o<_% z-kC%vu`_2rZDHa1=u(v?e-jqZ(Is$#E7!hyCYT z?q%Wm9(7wK4hu$s$C5h9_hY)Lh1S5=D95!y7P*t;v!(uHoBA>~?n-Ix*ptLY)35#NbtL~=U4Cs# zBf2MZ?X3mX5p2l4IvCr71^;5(m-z6n0-3U)qjKze%BmP&Y^3~1XMs$|;gnd%82(K@Guii{> z+Q6SSMkoGt{e5IfKFQCmmT!}fkhl&~#&jIX=(yof?(q(Smv;g56D04#7cEga@P>`N ztiw}geI$6YdbPv2jg4pT^mJlq4c|A72Xo+h_RLr?;pgU|We#jZ zY|0G4!TPJ5(kFBdI__E}OB?zk%DdM2j~c1dv(E3Pjc{;*xt_oB9tUb&O`GG(IG7~I zFjN-gS@zi@aZT*NIMVfA>m5jo6*F`0By&L=IH5{f@Yky3G9? zwV{C6HH$TL#HoOT#q?qOJFhtiYF_VgN6r`e_L=5XCto=9DW&Ob^~Guf17k*tFFqAs z+jDk@FI2T}m3~$C#i3%=v&-iC!cwYUA%xiZTK2$N0kJ!TyG2hlhv?F$VL|QoM6br2 zP`m5ti&EBbM5>Q3R=&C{>%#U$+3-QO;{{*zUR;+hQ{aou&Y7j_3d#4997APkF5I}^ z7suE60-NjGbE@wEd ze|w1N?9}_3xl6d1JkEM}eK{A(s@9VZ6TPLgj&ga&3N8Yaj~#X)`pPQbWTT227md^V zu4QcGV(AC-xZN}^p4u;PS0*}*^P(kUfj<{N4VPVKXK|4xjJei*oC_Lb+l#atTy%7; z_qKY##oROVLcZ$r;Izzu_w79w&wsz0`H|?;5hM>VX7M0A*sNO<#lrztmZciempuh8 z?(e8Pyji^(FCuyPe)~>#btMm(DVG*K&g6k*y-=Zsj7vt2R|vd#_;64zoVkk!&%uBP z<18LJ6RZ`N-{WCJzS*g$lRO+&ESRDhP4w)Xh_9!n_@Oo;f8v!%elTzEa68%0L#dp7 z^v@z50#C+uHlz}Ly`F34XY6dX;gOk{`Spt!|sw^PuB$vG2cH9vl|7 zpOQ`Cp)2w^HJsRAmg1r!Il_a!Vdm(|79L)_4fg7*;-O(h^^?}?ME5`I4waANL21X1 zgZIh0>uxoUp+sLFT&iJpgvo?PJhVTZ8c{~m6v-l+!msdNBWb+7~(?ZqLH<5lN;`Ychk{8!cmBjrc{UPK27OA3?>A%4GO z_v!EIRvwI%reAHK@lf~L!;@ym!_cV^Z$~nYh7PS9Cg+^59ho%}M*2y&hD#y4d9bD= zHu;?){oU7G@traf_j8lpJSVvGQv1wUe2C-&b)DbsseX{0ciCU9<%jW3>Sl+}|hXPP*hB!OOwIfE%9A2o5#oU0r48hyCBR zPp!@O!-AHngQ|P|P&fE!TSST<9MkmXPu}T=ptWWnHhB4Ad#9OZbdn#mB5uj#r1@cm zj8u_kjUTF1j4wuP_rrXPq1Rfeepr4}cB@^oAJW?+evgOv;mb)w&y;XKDD6}-x47ho zqn9SD-pKXC3s(7_ALRT$HdUw7PWYiAE3#!{wI4qCiR34@`Qfs*YhTSpKTz$z|2bdl zhda|kc3S3nV+`y+DqsGH4te-ugSoVir!kLN?DdLGFKpmo_d zcKxgXTu?e@<2Nq=C$BxQR$m@~iGwmW!#V-zFMrf=Njd;WR?WVh+3pXO{}@`qAN=8N zn=&V2S^#Vr5wdEf{-|0!dHc%{f2^(PZW!3>kD8@|;&L*2ADHhcKb!m?cv4HJ#}C>9 zUH_Q<{&@4`hVsZKf0S5nZS46*>}%a@3_)ps`X(H9r7=EP|}Dw2n`_P6oT z@ofV${~909W1be;rt`7VYNmhw13oT2&-B)wCqR-)!oBO01n?|(`hKc`52p*8BGSm1 zyKHk}+C@J0Chsokis$2Gk&>FteLj|Hcb86_B7m#uo2pfLeB2oHf1rJq59>fz>tjWH z%u1i#clZTa7vF!Kvz-swEzL6zT;SuB<4T5VJ0CqiYFiWa1PDFf=h zocZ_2cFB+ct&3|XwTuYxjaJ*}oFTx8mX978Yysk@YpPT$3XqsG;=23}AHVJ@>Waxd z`J0a|XeIX)S(Hy&7{iCd^#(21t9+Om6}+h2%|~!j=->8OJ~BSG8eBNXhg-`U@h2W1 zE?P^Jp0oK#Ome5cj^rbXrDQh4knB4rrr<6xzF;fi5`gXOa}?dlG|uh-l6$Y}C$ z=)g3!corYWWO4a8c~{s`d$;sGd_)ZOrZ36oW8H11Pgbu<9DJQ`*d5}-qC3_&>=hr$ zLK!prSprN;RlXRW#z$Ff`<^{i5~s2Cr%Pt?p*lm}oyrfu9f826bx#0pg&Gt`SOs9- z)_B*QfdRO*&HkZZK>(&?F6%GJ3Bb#fVcQp)2EasB@?Mv$qxDH=r7jDAipdAXvef~Q zHWIJQR3hixUvoriQviyl2YQCC41oLQ`>XA@1Yp7I57SiN_@m*ng6=}E0C;n6reEJ0 zfTUH~9?5wDu->USKdU1ESxm#Z?G> z4~@Nzp}BmN8~-}8v6>HY&29DCTH>#7)8sa*3Se6>vb{k{fc1Zt8k{5eX-t1~DcW9u zH=+OS+`CMG_Zt>2x7#B??7QqMi~|DPS@|$V_p|`Vz1Omq>I-n!J(^qSEIOm8~k@uaoU1zNk z0IimHPb-KYMm)mstL(lQ{&UqUGkXja@D(vt_#?pGn(Ov$?PhC0ijPRUu%C15t;Y+;)KbPQU zKSWB3k9j%xBRcGjlcu*nYT4-*{X+d=+;c7@&&MByQ2kSvP+d_`_zqzOqFtxu17aU-PyG*V_}(ALit1irta>X>)KE9Y$)oGyMh4wpI(l$Ch;qH?q96CV?!s|-JE5g?5sezFJAhu=UyNbXNGAS z-wnjuXl7EXxT*kag?a z2{nO8Dc4!!_&AX0n324>wSm}wbCOTDXAmN6{k<7ILFhhXXt8gO5Hs%_urKHh!q-pl zhM#{90;k$r$F?a5CF_c}c2x%9$*bj|`l29M;6iO^WDqDp-HlII2{GC?aZ}(lAu?v9 z8Cx$G;(q66*1y+5m{!;?wPmpoS0*r5G1m)muDzZ1{Du$%DSv#=$_TNFFEsS{6olu- zi|h<+g)mqnGyW$b2(OwOSj*N0!EtkwNW3%%D@H5Mygd|zlml^RzP|~=oVo*&V`P7D z;svq$SP*mty-VFD3sJqj_v*ddceub(f5ex;dB z;ZiZaA8ji7qajA^leBvjD>42w=TEyfOHBCgyPlte7#FY1D^Ok_#=>`#Hw%5mpzhs~ z!Mh@cuuZZ2bF>)$n5+A`cZhML{VU`8IWaVr8PJWh#c-S`S0+^`Ms$R+OvHCF-hcT| zBf(sPj^q#RG(8F7dH0+NHWI9>7l7$2fsyTx*7>0lv`H(6?`2DHzq0zkbiM>LZ^{|C zZ;~KxO6vG!js&e0-V+VaOEBBialy7q3BG9*zprePpm@X(8n-3z(@kz$`c#6c8L~G9 zbI3ir>(<}MmO!~*|FuP?1X7N_7GJw6fs#5`r?OOncQ>!-Z5@(8)$(^=`ods1b$-p9 z;~b24O>{*sNiY)bvOaBg3WiZz?Y`D+!MHP#ooU5HbxRm@^grwdP^W^G?RHo{I}iJDDZZ) z1TD%7yq<5C;LO=4f$NPW$p3s&8j2FU6knA--6n>v>#v6zpT&53YV6#u7BM;*?Jk^Z zf=h=}#eJW|7_+-z9X?9(#BOJ|Qh^wzZB?_4PKj}CZs*&d^)uhqf*22?&Py# zT#3l2o^V8rIrTU1OXrH=$!@bfKyVsfQn%$e!EK^L)REg{ZkyS9&EHmxncHS*dc~7` zyf-cN@meuj4ZfA{yCA}Q?Jr5OB!8`bMB8JY*`l+_l#W-c19a2UYBYT&vwc24Z z&U?)L7Ddi;pjiG~&J!b4-t>WkvlucuVYAOGkUZ9)1}!6b);rN^;7h3p6%o@Cy7PqS z-*|Q{)mw;~1F2uW(}bii&iG#EBt*+X{RpopAztcko0KRKf-?Kg{GAy>%$Jw@PwBc4 zUgu`q-qB3>wDI`jwqYUS4?mrBaE=JUUvFj>b_o%p@4ubeFNEp-2VuLXiC`P18t%11 z1n!gDi=1}~u~k}L#dAO5L7ShCd$~gVIntTsc|eFR*Y4hFIYOwMypiSMDT0-5QFhuR z5pMZ4wcctI!E~@U?m@B$x%&r8Jat5<&pTX|x`)`w;Q8)K72-rky~+(@r{65jNq54# zi7c128*#)ww|UCpi-b^(T~is}5QOuhJ0ZW72`?Y1dUJpy#MLQ^)}G;{9_XYlkfM-% z$2Q!!Q%mMKa@~qSLVQdzIjMhEh)JD68jIfx5xC)BhOe6t3s;;m4ObMxez7!rzqt_m zFZq_=X$!)aP_B}~A5t$lx$7N9NnLwzcf;Y8LfCdp8o%KY1Utj3w0&oS5O_7hO+?0T z+!%AI!XU)F|Lgm&KL}o98EbbEJ41FEdc-yc;hmHkONk!@gV2>T7LqzQW|GaAp%8?o zrXNDamq0X~n>0|jA_&#j%{=8Sf>5Mbbayv52=nDuYQ)(KF}Xg3AzdUyhvEsh_}fAp zb7d*p{?Fdi-6>I|K90#M9W^5L_eDM9-8b?szf>#w4f3u9g4T&oHG~LycOa{W`0JHZ zR&pd?h_a81|0ofEthKtB`1rgKcOy18#fAw%^Bhck=p#gEkKwemK_Mc(|H&G-Bt+h> zkU7C;2p)>pyZ=oWLc#x9NIkKOX%#qXe~92|hwHgUID#DpRA2Yg0z3N>4Q}SoB7>Cy8 zUHPgeh7C`5I^(YhKR;C-nEz7*n^!HfBr+uLQ>^|D1&Gl7NA#TOD8ihOicf zfkz>E*Yt`92Rv0oa5Ddvl267xwB`=&1tQq*XwH(kCWN-rKD}y!pJk!aU;Qdb{`sHZ zbgW#6W|@w=F7ZN?~xZF(f~Je0thboIjTLwuU>i*W8tUf}ogNWO>d78$&lUb+)Xi**zRnk8Myb_^ zJ<(kv2S%UlAu-W&JM();9qY0D{`h*B81oYDzGM=;meF#kaQXpa-{9}+d0ArEOP@IQ z^9<2>>kV*~)T<@xGb|U7bNm%t%HE`lA?*?EBbJCUUNYj=8YKpk^>lSaq8R7udj^7t z-n*$NYzcZs>QQ!6-D^^3w`{cjR!(!_7Hvb*jcTd z=$Lv@SVUKq95mqU zmVoo_jateM2~O{-9Mw7_!C&18Vy%l3{FB|HtxfdWjCT1@d!o;_?%5Z5o+3e*xq%*) z=n3zT;(}o^zIM;fja8H2$#&&GJu@YEVO+4lkJNkBcP~mbdx^gMOP^3!DTczmcMXDa zF(xiKnG}{u>ax!IDM=(g0TOjP4Y7Dl1(z< zuj@O9^JR!0=yr`rkR#`dRCLy^B{=-9-5dJJM2z(}XRj$SB0Bx^8`h#_VoW!XS!dBq z_((6y=Mv$E@E8B)Q$Gu#tK;mjBLE;_}Wc?B=4y-42`}QdlH^Nhw zK3r!M5nVvjjlTP)T!bHj+?9t250_<(zA7gDN64epoCIAl&Lo-z#kml@UiS4-1{pgK zrdW3e5*;@wWxWsSH^jNkAOGoy@#x42tE=@QaP&_n$`gKd7eo~nkiM$C+)^v>DybiP z>7|haq|UukEZvzX!s<0SV@JoxXnmxwjri+QP>^x(OA%fLYLRAu`6#xU}=|LoH>K&we^njRHCnzducfT zAbeUQFn?7?bXpQatui@VM09EFp*8=BAU4^3CxpZ=#Z^ym?wAONe^eNC6_U7bv;9*_ zaIoj4!y%(|!mIg1kF8dVV7EPTn$9i}gd6O(7^D)N)5Ca`|4ampzGmLs6cJ|cUAZOp zkO&tGF4~0M5~1K>;hBDNZrEG3$5$hW9@_hU1Bb+6&sWdt-6Nz1C2xvSpNOl+yqdp=!S+1-@G{{Mw{2hdu$sk) zPkwPcgy`nSk*3qHnUlE1qwWxii=CtL`>*nZ@60w_8fh0{?;ktX;9U{Mx>8R!k-pW# z{Nan)WPO`Kid>&5v2TY(%~=~Uz8G#?=tla=_Hs8(74pvNbF()25nPzGY}oj&TZ9qm z+y%v-i9RUF)~qHxZWPvUcJ@2*>w%HVmUkj3A2`%r=_AHzk-kamNikeaDX)uD#V}4h zXWrsUbjBz(_z|&hVXUQVEa|_swKmvIB>irwhpM29CC1W)kF4a5kv!d*JB#s9jCT*$ zbjg;Ge)h?AoACjvrPo2gSP zh9&4axtCo<>WS&3%Q9_|68s-fO9KQg000080000X0PuI*UjP69|NsC0{|5jR0Bmn} zE^csn0RRvH06zc#00000dpZCB00000omcrkl;8VKWhsS1$iA0ok=_ww2&5AQYnd0sU(t$l4$*&&%f}UU*>h5bDrni%XMG(b!V>K zxMn>?YC^<>?WV!3aAuh4GCfl}wza9bo+(=twmB?FNEd|#v;OydWl(51i#!kK28mf@ zJ;!q4!iDB@&GlmR{=dJe$7SB`QPHE|!JsQFxfH}w7Up`l`NBY5O=;5&UyOeLIyvI7 zFV1b8Uwy5~7uWqwp8fjg3lmz?uX=SJBLsrcf7dzQMKwJ#Pe+O;}o zfiH@xC;Uk;B=1dbiLP7Y3#wY63uix2AULr)?yd0`dM?V@YGuzLRE{J~CNSdM=>AN$4^+KyXZ)sywlZMiLaBtFLiy&0@ezK{>yZlv|a z7cnOv-76V(&`EQvHPgNL6nb zE<9vVnr-o#y`5Sw?3x1#>)2 zr!a|~YDW`a8nY?ryFVfQ5Q99gefplqqF~Ra*Uw!0C{WD1xJoLRiVcNl!v9F9h(1%> znCd}=vHB|B{xB*YhKAbSF``1hWrtv{Ar+mSwQ}=*Qjk)YXh83wfPP6Xb#Vg)xu#c( zuJ5J5RjTFco(c+NENlJJ6pGr!`Clntox{BbX=!;uP6L(?1AB~*l-d5{&% zq~dhMJ>~o@RJbf(S>Bva#n^>ap2qc5>{)P&=C+QCwD!B_p6{k&K2y&~ewH7svYhA? z13%atE;IYP#19X8cRYJzK}Hw zXHi7z{zsFg`)LY>7WHuR@=5+&{rO%?`=ekbe zB^m`M6BPfPu_yIT&Cr%xLBZIh%OZN$jm(6qtXNk$y~g z>AJ~LEOB7HPrv%l`*PE#Zjx>k!7L8|GnxrMrM*c zzV~#jCUMHN3--OjqhfbZNswX!70&`Mnwv&Z;jq%^{@a}-FYC)s&m_+~U$6Mswt))8 z?1EIU{ZzoneUQu^Z@TfQ~(*GO<=i)Br zU8tqtsZ6u+{!R+K?Hqi!%%|e;tyan;68G;{=`O1Yu8*IQPg>+k#mW^kKPS(l!l*sx zKRZ&7a}ygCTdS!ku8D8naE6Mr0WQNAt#>zxlC|3fF?W zPy4r1(VO>h0duAwiso!?G#RF%@Xdt0eMMB18l8N!WvU;-lSF^1v&nwh^i0RSeqf#G z^E?pYhpp?*k7^10U@?kYoP`(tKQORk}N8+g}y6X3!s;Cf4+^eKfpStaL6=B>>J3QrbN90-&xQo@GJ4 z%Vf(m)p*iS`A}2Ke;y5EL)_EK1~kkGHJbRHL&F2}uVbOkG)PIf6GGi-IP~pnj~nqz zu>mt~khSMKp zpIl4s{VLp+cqg3(l?RW@qnc^>GWO=0b2klB=stO$Kax1v)93D-5&$dJqc>&{yI)w< ztzy{(U|#y9pfBw-{CYp9^-2#7D+_;w#J{HDsYQ{8^?Dj8$p_=S|N0}^&Gg$Ua*tEE z=f^YTyX()zyv)2u>|W#?Xx>3X*0b`Cp;-ag-^RLIjO44~@LBndH2e%(m~)cEH*ZJ5%G!(mkX18XyP(q_^NJS~HW<_Jd_$PHVFL}n z`v&8kTm5m^^PKCS5`Wz1IGtuL^~dRKbIWu6{%Df2I}k_k6X;tk;&svRt+C_mbb<%= z)WHc9BO3HZ=boQ`fQGB$H%*2lZ#zFYFL80DAos2<4YU@mrg90%U*p90rsXt*$joe3x=O=R^War_n`wwvt64MfiH7SZy!`6|2!8%L zh<_a=aWQ&YrMEKxu6(%vnn{Pc?OEmg<^W9gRj=l$&~fPbp@?ry0eG_Jjq}CY0H~Iv z*==|bfSG+BanC9P(EM%V%Z*BOREtx77TVE?u1FZ(JOF7xmcJN)Ik9d5ckc(FJ!#rX zJq{gZw=`}Z)1qT2L-%#Q2RWDf;H47%{w|GEjG5-1cf2KnCIf0QKiKm@9t zsEMT$yR4(ky{!XrJ4k-aFf)M*Ig^nG%!OJ>G-XBaJZ75^W5p(;Q zk;s(9dG4;S$u~(o-DtXZpWvgw?0Z{8NdWv!7tOnDOh?Fyk!?B?>9{xH(dp@)bU2yq zzP_-X_&sTh{6IP#H}FWC8~>EN{t z>@M6*2Y;i@lgtu26e?aHDc?p%^myx6RTnzEk9+n0DyG9e=H;rVCrKV%gT0i=^E2OF z6e^?wG2?Ib3yD=AjB`5q%?kn{cq*rBv@j5oVHxQ)^>hTtswe(^Nr!=)5=&1$5HbU{ z(iP;NTJ=LHMR4%=|FRQlSw927eV4G){BQv9E9#li)&M+u_S-Y#Bn@SiK}FBAY1p#sgS1U64F%0D zCvyvFu&61t^S7kI>vYSPPxdq@#5A_seej2Sj85C^m;QKo>UO}uWEyOr%AY&R@`u!g z*$-E)CjG1_sXd+at2b(9Iy&&jG-hUX);!XG4r~j&q3w@f;+Z>SH~3>NXXZ&!xIf1F zix#&E{oxapTJ??V4{GZV#ts#-KUkOMZ|{!`f1Y`x8tH%cRoX-={oz05_M1iIT*Qgy zfMg4QJc`?B?a1`U#HK&aqksHx)#G^adOd%5uHKSyaf&}ytxkEmmb~A$b4$cGVxNY~ z?q^+_{UJ)@3(j-=@!|B%B?s;N(bCu%deV>B*`TF0yu=^Yx($bPzx#pzsLW>GEz-w1 z7Bl9}^2e?`E$`K&&zFBKI9&SN58SIsSB%>IKz)5Y@9|GRycgVgYxa%kj&QsE?E`)| z#(cw?NBaHa^vXpet$tWzd-||LwjXx4eu&K6>4(m#8VwFx{1A6xO)Q1{Pm~PE)us9& zuRw2zOLT+-z12h~-wz5#W-bGs{$$>(iaqewAGdVZi5!(^&*n z{_OqT;Htz5z%8~OTgNQ`zLm1wfbvEPc$Xic0V~wM>8ulTjd$4 zKmO9(ycRl^*%wQ7Y@);3pz5G2!R5~LJ{4Dc2tKQhh?Xx4#BgHe_ZGri@q;w6I>E6% zH>O`#hv43}udQffAnG;=>e{^n!BS9fIZ5i{n&-_o1%yv10m+{SWCPKZH+w4e7aeCm znVY^E{$KqoKHdG3j<|`pu2?70k;kowDj@ujtEu~`FOA%LOd(^lkdFUaU-!6LF1Iw=$o(rc>6XcKyf&E@>GY8Bf||)BzG@&=md!}2A-p?z&*)2MQs>iC zY`yO~2SSv-CzfDTBHwcmmPmawixsV-*FY{_$dX5Dm2A#3LIs;*2wBqZ%SwYAy zzr9;ZKL|C2+X9_bf-pGy{ap*$Anb7TS#TjB2urs&%MW-3VOm+H<4_RU?{t0Qd?*N0 z=DA)k91Q}GvAX`tdIp*Yh20mp3@i(*^^l8YAj0tN(Y-VVc0FAgI=6@c^`D*(t*$dL zHgLsv{e7|)_C^nzGtrcb^QUUNEee@9%o~iz@n>R| ztIoelJ`-`phN11YObng)wDli_3By~gEpG`(V=GqmB&+H(xJ z&W!kwRLQ`#y3lg>?F?Kh{by70hynlN@>Q>HFt9oGcG2Cd3>1`DWJH`{U`6dbOQQ=6 zgwAc=yC#Q$hl-i=Up;2P`)~e#rcn&kosSsZd4z$;q)^S3UJRJOx!CfXJkOmA=EevH z40li*=AB~T{*CuzS?vr|B+K+?XfZJw+OtpLI|I)IwnDq9Oh~yUexeb3XI&1S6~{ZbBc*Z58qLzDkiKC zG?m?y4krD0TzujyiJLIrbfsD_?)b&#glq~%xf$<61cISr@Op>713AZ=^CwRnOz^+? z);J{?XGU5&E|dmivcdf8ogad6lxOySQg1L8WwH&kY*^UZ{%;R0o`oYtGUp@Cu@GIo zj9wE-*1wa@CuXqlXV^9>eYV3g8a(^tQagOP1n=y<>{7zHl&*Sv?Bc-h(N((#%JllIEj{bc<*K+0t9 zA140&Hqxy4#e~-9O0)WROq7LROFCS{#3frbh0EKS&}{OY^E-x#)>|gVuiQz!WPA+n zS%vwLhx;*Xi$ytMc$W~O;ebNjBT(BP-j9ng7V3N z)ZOwIS+<*545aln9i4TQ0qbz-qR(jz(Ds(gY|vq1vCXi4`v?P{!+Ol$JR|449O-8V z7`T#{yQp{}6N}0Uz5Csm;BGPXTt#JK+4-3Tex#0<{j)gtehw2l0l{BNZJ4;Lk{I!4 z0~7sUXAL}D#l(Y#prIoPOw>FH{w|wC>hr4H>8-xR?nwFCnWWyo&EFX{S(AxR8@#le z2#-y;`uFFjFAQV{i=MLdNgOuJQup>?;#cz9g5~R&h&R{luXSKz*u4PudL$0vF*hO< zn3$a6GqGhS6X~j{?n}LxNP2(Y&1wr1X^W$Wy>pqEc)wh8fcU}Kp>RiM1`{r?I8JJ8 zCZ-uYu1pDJf-HFVY+>L*c(PLbdj{lUwcVxa8PMG^?}>9Q ziBrXt@U(*rtQ|T1vwSxLhb8aN9V}!ZH3XjZnGBftIX_-VzL%=feA4MA0~Yk%4^A9l zKu5Ek#D~QFSYiHLLk6m315Zz#%)o@&#A6A)K{z0Fb;m7GYFi@|E_HZ170hNx|`G)xbxO3Os_8p4mwLlb>0S{ z`nFu^7m{~pLr=Ar#7^n4jS-!MugBy*?UL{V&LMHDfGw>LGYA%_1Szp2)^AhzLCBRJlWn9sc*>u^;V17r5Ow;Jpb?FlT{3S zh}p7l{w@Z-aw<(k=ZPP;a`KW$zZ*|llE=Tq0Oi4_kU-K$H1D`nt&U_MLF>uG1ahxh z_=wip!wk&6EW9(Jiu5bF9Xnr=zF8itO=k(o{_XCb_5U&OcJk_D*9U@NcC&H&(i^1T za&8x2^$WuGlref~IO)TCF6ZhBf?#}k?!*lng7Bz4?ccX8Wc}sAmClMF`2VcrAE^(5 z_na?7XZ{A^VQAByXQW^6xwG-6{8VDE;n}BC{srO9rP8Cf?+3v(?RbdXP!QHFST`{> zCJ6fq#ufF_gD~<(uhTp`2uGexs(8>8gyk~?1^;yfAvmt_?fvZk?Hit6&=`bsLmRBd z_XMFsIOmbWqaa+HW;jFlMG#_A65j8SXP|$p%CB_<58hOZHPR$rchrWwX4H^8NG~`t ze20Phtoeq6#l)`nugboVy5P-TwIHmSfw<5O7ajW;sJXy9$0KojxN={#;ywl@E{&b$ zMe5sR{ink8y9|T{*ZK?(k^U~QH(G4S#G169X|t@D*k$%hlp)Q;+l8G|!w8eGB+jYtuH8NvgnZ?)>VBfP ztffZ`zS4tm`PM|w_3MIg{&1_yJJ%r8A8%fNgXl8Zg1Ehhe1ovNe&)xPs33fMvczVO zI0%#evtKxWQxG0!PBGlPHwa2q6CYm555g;ny4{hnAbdD~@J{~6AVgHAn~o4%N2z8^ zRa!=H7jnG(MHQ7?mmv#Q*t zT_;HV@6qW;H#2ZAyQQX`)R|gFchYPp27LFw-dsfDb|*w{tc}!DNoG;e{tCh~H|L92 zB{87y@t~=P)a^m9B*X8X3>b82&Yh*eKx`Cmft5YsnHaB*qpl2aZpIJ%nMw57Tj|L; zgg->D-^l2a_(sKaAMPUcStY%<{T9JT>R+l(d^!U?+gpYVpOW+O*~W3%3?x3}yzeGF zcl&jEpyopcGRFg7_Y>Sql-6yPY9jZhtTZbpyeI$J>a>*%;n9q9pCdjnaI~>^;$+e< zJm)_9bZZ{LQ`PzY<{%~%pV%&?xiZmmHgD3Dy`EXXDCN>F!-|5|B;=|?XW_R8)(f;lQ{}bt} z;e}BrQ>>Zr>2NT(u$Bp%RcpWW%nZg7zvLq z@l55!Yj0w|bCTcTGACbLoG#nL)-^oUwT2~M~t~GSFTpU6A@Vf9rGfDrs zc;sgO^P@}zSLE$#JI#c~N9ynM4@n%lB5C(V$@g+MP20I77~jI}-ydHcjOhmyinZf| zu}MIGzzqpTY6HSwOe4B=i0AiIDHv0)Za&r^AB>wf$cn1S5jI^4{Br#Muz_F)S~`i}ez2EXZkzsuoOTej(hYyYy5v1vX%VwjC@A8O;; z-m-DQ@Cuz##74>Un+s2q^FdnLe@p7wko=zUQhl80ctv@ED%@So2@wO*ULsdL5A=dcLa7ZG3(V z8)lB>9i`;l#HFTHwVrHfJYnbfc(bu=UqY9)2OHH-q5}%2upxBKePGUIVg2}=Ndx&T zWNw?hLFxt3^V&wmQeJGZsUBCybxY zLD=E2=~pY+P~BV}AAOdM>(^lsol546b>3}yDQx(^c=by21{*Jjlb&fEW5eju`rg1z z#822c!z-4Y)AccS%qH{A(2t8VW}B6HH7N#94-xv)`i;-Tu578ZUo_a9Q9!^Zj>u4=}QSTOLN_j8R5 z8{6P8sslFkTdr3~uVCZYxznp7$$O9P#!dK1=BqmsHG2D3v$1M)yyBTW8=n%axU&5$ zxORLw`;E*ol84J2XWnE%|LS3HQ-Z@kJ0@8CCiBIarjVqMN z+-Lf_Y}RsezYis=N3E6xkDQ?y9miOhx9rvg8kr~d&KPm}>%#(j$GPhh$$S_eTQx;6 zkA?6^!waKXWKNn~J9zLV3ypKL7-1tUjPAX6@C~t_eL&Ft(30RVE@_f1iDOEVinK4u zx5v73e$zA9X!K#`joe{l!d|C|O2p5R?e_f~GB>L6+gsvE9_>=3=>r7Unlgos5g}}R z>xCVwR~FY$bT58>$aRD|4_& zPOGJ*jg3InMAzPybbQNj^{vU>`{*yhl6!~`%?zpIgq@xws{f9LEQS8 zbf;tvTwUK=KCa;4x8+(34=xAhCK(;e$$J?RnVAY?pZfj0YcPX@@tA!30|E|A`m1Dj zCUNlQ#r~J+N62|`hDU4|2Mxt?4aawKP-z>rc-j>XssBC45^FhlRp_)T`49)I>$}o@ zZg9Zrz5Ap-frH%9hz}Nl9JH^JEOL+FfbZaV_k9otNh^M|r~TmI!_}Vw?s8o0{AzdM zqXHKiW@Bx;T)6mlVw3*SP7Z1_<+E&0aKOvDwj*y72lpE$`fSYQV5xr2g!eHVOpaDL zv0Q}%pN9(DE-m3;w$r*BcZmI_*BA7ih~hxOdNgsl0|$N2d_LLi;ec*zX<8>w?rE%< zH^yYe&VTJpHKJln2Z=*h*iP3e;(8C;CNHqSA0=Az+)YHwEo7n@F*MlKoW;#lhJ zupC()UgZ`y&(Y*TscFkr$y^>>h1R|+rtwgtx~4O8hzs2tf9mG7a&cfuzVEwwF8(uH zJu63whn4m3-F(RRu6^jSYh!cKt;Wf%3gg24KvLxf2^R*xUBj1=cr38_7qa~s2VWE( zjEryLqVDdnn{x}X(@i~J<{1~C;=fFfe$B<<@%!`2G2@Z@r(U6|NP*ibS%#DRs|p7ax+*i*Z62L zezeIYmyd#m(B?V&__#Nw*0a5oj}j}H>3)CtaQ`^2xIc>z(XtoMLo)a%%bL!KEZ{@+ z!L(P2cK>9($285x54 z=lA7&>kmQh{j$FOA3|_(OI%Uj?hsUFIwx0pl5?l8D3*UFewhra*j?sBtuOG7;tf6; z+$*WWTs~5|H0g~|d^~-*!FTy_K8mi{xYI)Th)(#%9wpDi%LR=|`$>ETu2`|X`AC&K zO}29+agnz@aEQgn0_}@RA+z`}nMVDz;3p4yvac*@!#vdY6`L&}@nwFhSs^`z4{d%~ zcAp0yKi$g>)6>cO^^x(R0utxN9*ZZEb>j8H>MM18?47Kyw_pn&^g3U@??XPexn-#Ms z!RI}duwQ5FcsR6T?7y29WGz}eB;&$Eblr+4KOb^&J$UI>+DaZ~CF~1rB+r%nbba^j zHu)%6u&Q_Y%uo_%QTWUUGoo z;I@~#&l-ZCz?{4~zbhopn-&-z{LRPP3xA}pej;%>!Cl)vN^n?J@1g!*2u@jAsx_E} zAZqQRudhWRSP}1Z{M$BygAS_CqWBPW6@Ssas2PGeBcjnDmk_)tmho9g*865rW0MB? zpuM*bvmiL*E{{0gzJ!n6S=*nQEF$0E-&heB%>#S-esyV5&qC=JHTu##^mAXXP@2QT z9G~c=*)vIf|8#cveUFP*kxEWO`aC#kq^{huo{LFyE3K$Exv0-eIHv*{sZJ4yor=d#dU!cs1* z+B3^vuI54#M|ro2OKO$4hKFq9XKQzzjeKeeUfDgR~-M+LQ zKF)+*l=UEa%|4CabMhd_I7sq-_k z{_5>@O<`>a4zhfkii<;VH`zm0`EdxiJ+!j#hePl>xBvU8Lm~KGc}4VWZwL(6^(X## z!G~kNT6ldIAHsRtou8KR@g|=#`#~EY{hpo&_PT~(my(-*S9J)~u4vTm-xmTSdX-S1 z5(>RAY5B*0LXhIsAAa~r2yVMAUjDO<*b}6iV$>7@_gyKQ>U%=4{IZwTC&y4EOy4!* zx#aAXq+>mao1IVN$dMx+I9(`5)g3ap%aGM-i@^h;Ii^acBj-3?X z`~vRn`%8cXwO_+uT!bi@ecV!6O;Ku zD4r-iZkQ^>1+mzC)JurX>$5YqUL)~X9~XD@q5v~pzEKQL31Fdkc9GUQ0dCp*+iz+U zU@Yjztac3{Bz3Zno&^eV`jt*+SR;wsX4$sY$3?hb6J={ACx(WlcXoiX7?B}YgTKxc zV?vhEi>))n2+Qek&XE^mB1_|s!89>OrOS-|J{H0EYi}@rwiv(EyIxn%5kr--Gwk~c zF=nz>`5u@>&fm^I@l;cc-sDPP#!8hFA(K?aNYT^KhBdK&QruHZ9WkUkxaltBdk*13)6~;q z^xVr;W=4ylf6mzLc9WfOSz>HRRgjsLOYAdNEk}tMk2`Z8cl3&3oqV>w*F=K8 zEq%?dY7!(IYt;&|m0;X8Y1tb;3Csi1Y4;=&w1%1`9;8cP7LcJB!jz!Bx_EExY6&*b zAFEy8B*BbEnRgS8NO1QcPx*J51Q7`V`}7`4aQ;=`vX)8-CM|ICAGsqzS=q8&g**uk zYP)ZdIV?fk>8wjSnG);^#WVK;3BGL)m(e*VLAK^ao0T6Wm}|GtM#C@+w1!;6q}5@V z*|K}Uh!=)`OZDDrAPoEKKT;i6hT;1OL*r0R7>4!4)arv_*rRRpHI(Q%f( zw}8}5v~QJNpcpM(W8+I*#BeKNX=jo1t}4TIQ`E#5v9=f(Di9&>>U^Vv`9iSP{EnTv zT!=OAyz|(Em)K!d!q80dqi1p9i?6O`6_x^%O_){5XT9~ab0M98?+ZX117 zgqQSfoSb+O{v0S!x-?aUeS)cb3JAZZCRBI2Y!>2QGRJs`@a$PWe^8D*pYl4zbP2hK zBPHsxnl6OOnk@gj^`u{IyYRV;@b~jYZ_EDYcfDfnMl-q)x{*f&rwN~rxL-NkeOCyn z% zo3`$~0B!c#UX%I-koxjqp`NY~EsKk`eDe@sU-@EFPND#E7p(2y9VY$j?;L~X3j$>N zoIKR{NC5Uot>cfY0!%J!ShM=R0K#95uhi)ROzUd#Ik{MX^y3#yMt_8&w|J|u><<|Eut@~> z2}Rv!=pwlO-a9IHgXH~tvpBp~gw5BKo(4vU@M7Ve5INE(HoQq&>m?(`j924}evp3H ze<`PHyhjAnp6K20UWwqe|21v>s0dv#(+n=H6Ct$4yndCn2=iMv{d<%kM90emI?oBd zsSBsR`V%3OB~t55bIc=(;( zukccUMYAp(KENS$HSNj9G@|EXBWRa{Vg#6O^t*m+rvO%5uZsWvrRTG{hKmJkkGKDpmnE5rnDdu}+v-Q3ep zIa5eJ<(GOW1`@nw)+jr#`yj&O+@Ntb(Iq?2KR+dQ7vqp-Xc5&?47=)#9qmLX{ooHr zJlQLPm#6NRExSp6#?K#z8nFgg`k>X_meSEq%Um z^js18+RtbP5j|FN_Sdy}9%59@*ZJD(K=M`hq?fl-4A#?Z=af|rPbB~MDD%G` zPZ#50de^IaF=9LmI1^|^bXd7{rmh$1GnRXbmBN;baZ@WRv4!lX9~FkqBzjHN{*PW& zwiusU95xnJ5xX_M&)!1%pW3+>Ik(cp=rik{;K&z)HhgGuO}H2g=M!^QCy8NXBCYJ0 zC&ra)A$P(^A57kUG`P2m=*+{CR>QYqLC)a&6$ndR0#@a^yWSxI&ATKoB6ly5?yDJyJ;HH6^Bm6>IbfuU{1J( zhmE!bYbv&GJF70ia^F|ai)U;n2!BbLqY5aYl=;xG0!F$_ze z)YKP?F)dc?J363!6$fTM)XJD;E_Ai#RxFo{X&)eyYWqi zIK?8EP3oREO8Oc1>r(l3q#h0S%Sk@hiSWf??v8{`5mp|dS$5=$FrslViL@AAV-xaZOo*K zH;SNt@ZQP9B*G(EOr4mgLWG^!F%VTPL}Kk|a|@ZX)Thp-&f<~!TcyoY4<+?%wpgh0 zTY!NTW_cYW0)+lr_2_)M5T*TcQ^rVNENCoA6KROB{MG3>^TS2xX5Oe;ZzqEDJG&=4 z)kIKyx+mIHQG_G%cXb+bNIjigy3&yFaQJdBzdmg-_PMk463M)>KYaeVBhEyhRYm!I zB>zvOw2i8nV&v5N>F`8kPASyrzd`iN@8oy+wKXDmmmZRDAp8_M?OmKH;a$;_4P0TC z5D$K*9v5{B5#?#pR!Vr#t*J?Q<(LT5kEE>rIDz2VU`ZUeN<paB@s3@bC&b(i;yVY@Ft4rv6RpJobZ)m z=ou8JiWNz{*>rJa$n%!4Tpbaq=Lcq8d2g#l*t14Lzd-U9dpu&#W@68k^Bi|>p$Ny< z+i0v&5JN{;!((NVyfg2_JS6&vX1(jvk!>RI`U72g(IQk!-RV41N%%JH(oMru((e|2 z{(fIV?A_IE5_?Ppl@GSYDg-|lG(LFk;gUWU-4a;F5P{ONF6P`v5rTO31=Z~$lsIc- zEGP5d;wR5@EA2^t98Pn(C@V&d=g|er^ogD=+J2oy{`XLHZED&?h}+n#V3{a_OHss$ z4iZ1wfUz=7NsP}tNn-}-tD8q04!v_F{GyOCcsxoBD}$j?PohV)oI8Wnh>k52cKF?F~dH2V3ys2#;8>#Dg#bMMji3qC_4%=US^p@7sG zw|kkCe2WNLz2_9(M~X1Pxxn6!#4mH&u{9~JB4n?>*qlfu^TDGi&hkVtR&2nOOj7Sx z=cH9XAan7L8HpW-h<%48$4>f8C4Rlz=(Lus*O^e3$@&o9o!foo^KQbUOL?;IYRG(D zw&!3W;f)zOcUA2E6XWwCz25<1-dLW=NirS5_ytB;65 zFB0ATPIx(J_m@E{!ZWe#*P)qWvVXStk`0+NV!aK{uO{>E2F*f#2RF5KaeQwM$FL`qRp!F7yPkY5k z8%gww;E}%fXtlx&()UVdtY;Mwn|8k`9499-PGB%913 zgMDpxuWTprwlFhw-zLVZx0;WKNWG_LG)QeD=lpn5cCQG29?2?9$R_t@c3ezPtrVm8 zbwIe$Eip_!>P^Wcb63Zlj)sE-4~u>C8rE1#@F61T>&}T1%(mT;@q_U5=|`@rE&oaI zDmQBLDhla4*HsOS4J6R{w%Ozlnd|M1CSRXI{!jZIrH&sZ^ZL9qw-uTsC{mo$oOwZl z^)Ulmbv@}@p+mimLJ9s4P)h>@EdT%j2mk;8Apl*YB5?oz|NsC0|NjX9 z6aZsvZ*yfXZg6=401yBGKL7v#0001aIsgCw0001;SouHH-}kqcR4OV`mXu^GON%x= zEhQyU!i*VXX3Sui4Ot>B(qf53WvL{kq6Lv970FUbqa6FQ=@@A0aE1PX{!9Tw=92Y53@9P)dqiII_!fv$n(KF$B{pS-z|+VnO<3Xae85c%(=VDmX=b+a$7&=5aW zZfbNznC-%$KRK?j4lHR|+2M+@)~bDn6)E^Lt+1fpo&sH^vD$*Q6wEib$*)>KLC@1@ zBMUPM+~odqEn7@MWc9IQSI1o8Y&qqc)NBe)GB*Bg?{P)S-4GeM2vWa8C zy0w{RuJG3q*%hsJ#f0v$(&H;!@q5?2nm@Z;QQRNGv-#u-)|D;$I|f}5J@@X`C=La! z*bIM={1+=-GAJY01To)c{-%R{LFE%JIzv+t2 z^UlvHx$lbg=NBqyzH`MbXZGC?@;h~TmTY&ME8aB-6l2Nz?q;6p&+2u>*Wi~AM^}^c z{{CqHCPjhCe~;3C{Bgy=km54kK38P&v}9!76Tg;b@9o_~fsTF4x-Xk3up3=JjXsBh zuw~P~Hj#5(#$Ts37rJ8k{CHGfp-y(LOF38k-;6TA| z`#9Tb8wytWdaoHYqCg>vpUWWch> z*4h>d9QXN0H4jm+Y4o|W!w3cX!PNOL2_AgfW)`1#L&4B^UCqJ@3ZmA{n>K1o&O4+( z-+2oK1J1P{GWSz3n0C8Do!sN@cGK%wFa;mazB*DWCjPs;^5Ek@3hsOeloWoafa9){ zah6YoJnzxdD1Rz0QDrx+b*4gDe(!(h_ENFpw8r>zg0rNed$TeYQBlU5l)6Bgieh)_ z#2~S&O-5i>MC_?@oqz9TDh0Q0xYAsm)Av!ZBIUwn zzdIECt1(%otxC?XEQ>!(@adnnGkUrO6)S&_=()e7;8=%XHjChWqAgX+(1?oIa}p6- zHxaylYmALIBkO1PIuGK7^ot6%tp(f8+YJ^=N>166 z?{|ax70(;Fq0npmuH^+P&gV>XGEJaDwf^z#Wsj+tGXC_;4HCatSI-kd62ISzsD*DULzcnHF zVAkw3$(N^MEb8s$d4zwoUYxdmR6>Ep+|bcm#6HCznVU6wD5$;23|~psPuZ~bQ;Rkg zy2dA;s)SHsuMxxcCwP*`?V4R+K;m{WkUJJZg&F0=jFxCBr0#}H{&0{AbCb#J^eieS zjF#?>Pba*$qh@6_gNm0~v723!smK|WyiiEu``J6ETIm`UA-+?-YWY!NQTS`O6TyLG z$%ebs^8`1mD$>7dkT~!Y{4WrkYj0;R^&{{0xuZY(-$E)(?XYbT;m7xTT#u%S$a~|8 z67H=ddG*~TVin2ne^>KOqR9BH{J``Ge+sBNGu&HAy(sLP`p|*!sr3D}xJq{l%(6Eq zoQfs5AKgCTP4Y9pAGzn$@ie77|+(o{W0;(1)CI=zO9y7E<7*$q@ozU0mNKyWuLuy*m)Dk{{Cy;Orp zKA0v_=U*g#{;EsyGf1H#ysiJu0Tnki9h~d*c8VMHbmBF+B~+a4o5=~4cf$hr4>{@v zZct-HsQIM3!7DFIy!WUZs(o$tE;&f{HN>5o@tunGyAs!YET+Ql@aIGEB+s_l{{7(} zM)+dV<7>O$lKU(Arsz1h!Q^P5tn)KBTuE;CoZsaJg}pw}3-{2VzG96OK*#EMabl{ar%0LYr;s%d<%A4pYzY*G%y_635XGRuLe`%=tX{P0L ziVm0MukP(UNry$2*P}oBbR4U;dT`8{jxhOyt-B7;abjwv5yy;<4D&znlCNlJf2x=q zeTasP58Fo^n`k)Wrn+l;H?ixk%i{0~8cqjTRvr3DL)zDWw(m0N*mB{7@&Pq>WEig; z__@R#3zf4<3k7uS`Ic$Ib*1A=>B6XLb2`i_)fYcoLC3uJhoUbB(ot=v*J8Jmj^<#6 zN4Y#YlDFJ%<+#wn3?J?oBxBW?KMeJI#PmO0nzI(qq5tQ>E9kLcLnapo==qNG7|HH!-WWdGC7~;y1d!e?V~ORHd+u3mRS)jP5-1i-vD9M*6$S`!sfo(~~*~ z4tURlPp_uKR3(^aw2Y3pX_V5EB;xl3hGBXr9Y5%g*4Oya@xiQ9&W1zB!?`kcMg&)` z+4mOe`qJUoz`F5~d=FC1bFGV}qe8@bwXuK>LFo}+&*yY}Rdt^J<}e)v%WAdu%D6+h zBOu>|Mey^;Ir!2wcNiQBIA##lbFER^czWemi8VfzJ zO1$$?sEP*)Uta5yS>%Bqt=4i@?jE=+yLGi1)dSY<@fizsJfM*ODfzRS2hKlCspYKn z!1h1=S-}<_FxuF)DqyJx;vI9T|C~H9`$lWpo7o;XExFXi^d$pT+rR7Gu3#W|_~rg$ zDF)=Vl~!sGxP#@V`?-wRId?R6YG*YAKUe6T@ath<=DBr!?_@miejxer|L#j#o)@um zg$Fe5UWoBwdtk!)alpWO5BNMi*sCh^fOuuko>zGuXq0CU*C1|6AB^ z%fPJm8>pY*fw`7S>I`!aR7q}iZzBGg>9%+Kck*r%#S_%PW)k;ol~b1F{c*euH@>O| zrniI*E6?@7`0V*FPAhp}@UfiOv5|oQ>8hcLBnEEx{2e{@#U1of2MOyScg*;$HsF%# z4u{aY(e735SmE$ZVU9EdsZTea@y&II^~)i3r6_kSUib8}#ceuxO#a%-XF6vBroYOCKz z(r}cuIwsVS2722L8816h*S6KR$ZVy7^>0-UOlTPWOn-i3Ee)&8^i$lJG^qJY26mCU z^1CPH;|DSMu3&yp#h=udBQdm4b;4hLndW$p_5ASSg__$IyT}0~b zR}UGU%^Di^SD2n`_NncDV{b-JT0QW^v)T=s z0cn+@B{VRfAEs3{5q({{MCajv8{Q|(T+1Q#JR)v#X_Yh$OJl#()8uFfJ|Q#A{Nje? zRu}quo891|%a1W9>*Qxooja20hBezmX533~Lx$7S^E>vqL1kvu@PDyx*mC;8UB!Gi z3>n)@>A&m->ATBQy9$WDPrm=}EwNXpK>XK<@Va8S+SO14I-Itogxa&{(E3uot(Vl_ zM(WJI$%I#jPKNFXC-uelqfO4U7?v7tK3_H!X5ng;^lIKq9U9*94lim+f z|0BC@E;Dz>VYAAo8H695w*?;|Y|0S(%DKM0;r zx?VP|Cw%4e;mH-2n+FW_r!Klmcx>YOFF)EE54=j%PvS2l{G?z#sd9=3erN4Xsg>}+ z+NI$)hon4k(BO~N9$636a+tkkaSR*{F74lZm;tL8_rdlY@;i+FO?EE>$IpN4S+tD- zet9OXm;7E~*Rs6r3>gR4_0wi~AjP`JBhSnO%HCnz#`%QLtY&|dF(G*XcR+~0S>1#= zSC4cfKd5=KmzKPOQ z53GxtP`2Fa0s9wQWNw8J-mP3w?-t{MQwi+!^b8LiNcenka*hWkIX$g;)8PS?%ARR1 zDop5mZmsjwXJXH?9Sl`XCf46^c5Iht;t{NdWDD8w@BFOfRlvrX|7OktyI`O)>yI$0>S)<2h% z$HJm!YFYI|7XBFK2kzX*!ifiRt2Z>Upu*k1$%@Qhw%P7p^n``o@wK<8B`lK1IyXXZ zvk-o4d>{&LB>4c1#+-D>0N%@E0uh@_{P#0U)z{X)N|w z4%$5GzEy>DaBGj8dSN05YR=jxWPLdJ_*!{5A%}yFth`5lB^)F>6}5jK5{IF3&CHLN#Gtyr_-~tq1RD)nuK~8Sf7d;<=b2_4U-|JTCf&#KGlR zT-hItnWGF`#H%4(`>RhwO!jsr>Jx=MvC zE;%Pot@_3R!(+!B^8yaaWV7VE@;JEprE;R-DhH~HrUlubI9UD5FxBKb2fEQN^R{Ji zkoswN>W8-+_|%L&r5bawZR#?nrwMftn9JZm;r`tj>%c+x z8oPYjLJsbkG*w8ca8S(k?W^BF-m!K6&i+{(v@h7MGro<3bB1yby?vet$<{Orz2yn_ z!AqZ;j(Z|H;vl?Ecw(-x>eVl&Jn{Op$_0IfC)!d3-K?K%__?pzbF_<%Z=>U#LqFJ< zbv07n;2#?%OGd7%$&mA=OU+y}z{cmrj^{qzW~1|)-?POLY&h~7dVG(t5m1(*J>8KF z*$?UNOQ~#(#;m(EX*(OgTo*lFNAmrHJ@ryAyuapmW9H4`^ftw4z`=OsIDM9uW?zD zReXkp)!vg4$YG%-+xPNKeHNDP%W(Xt%0l)czL@fziG%h-90xrXG#|fFn66FYv*3=x zB*Lpxx1Vx8<3aWXy|o%7^K~0FsmJ74m_O|FTxiY0t(r=g7n&@T9A~fDKFq{O(CtXI zk4$LnIl!17%EArk&hI$F!j(qVQR*cYq~fi9t|a$ZeEYnyR=|Rycjuq(G!_cx%@o;- zNZidu)L%=<`Yi!%1tUy6YcBKi`oqM&Z=nTu=%j8*R|*!aVqq;)>DzW*LGpWz_9!L#6 zVj^}oz5TU_iJcprmsTHTVzUwA)Wevl<`^v1C3X7D=dHPKBbjirlwYfUkBN9Eq$)gR z!X()&nmUyQyTi|h^Bh_5_!oHBQ<22sVaqE|85S&anuF%NU_$YRn+$D~?Bf(qH9gKm zOq=&WVFDAUzQTOv6(**4?S7&_);Uj8og4q23EkBTwO?m3aU$bW>y^=QR^D+hq?O`9RL4>2G>Hi^MgR_Z>hI$7Z#}+_u;7?mJ!I3!f|82c6tzJXnxxk#XPsxES8-Ht2J!pDJ34_bq#hS1 z_MF>WPwMhym+mR~+^b3b;Y}uWGka?7Jc8#}n^==J5dY6v zQ2gws8w<7Oni(qd2(COV51n@+xSd>Z&5HP^eUAR_=yl|t; z_Qi}z)~{zB`?iOL?O$)L@IS=Dfv`=p@7b}?FRS@@&UT_7;?vxldYGtiQeE#olZ8jE zj_vucn2=7mqM_Q$gr}ud%Tf&%CNno0@}DqKGGp-4qBJJr+Qi2w#NX#mcuj0gXW~Y8 zn7SQ@i94S-v#Z>gXu6iNf$Ge}4wLok$2^$`*^%z=LFTpki%dQpVZzEr<<5&eOw8Dl zd|?rpmx`3EoEFVQm!{yHat;$$ic=?#OR$i;c0N;s;H%nfrX=t?wKnHzQ-QuDXAo~;9c>hyo2yUMX;Ri9>NEYJ7(s% z5#IGkO!{rYBf990v(|n~7MyQic~R-f!q;Pxhi_4d?mG7GKgnVi8g9m9RFXK(v_7U4 zA!H%yJX=!5h1~zt`0yHn7dOwB>Pc2C6kR*+=e(1J#5@Du=eZ<*et&k`+t0+mYt(`V zWE^*Su<{yN_u=J>BdyWfkb0O{_4=C+ z8^x-ex~rZ<2hOXvPHtML^=!n1_Lki= z^h9>J@6Mq~p7?q%ID5_|q678SL-oj5l~EzLqLJul+ZBevy=;uA%}N$uBlXkp-NRZl zHY8OY&Uumg^wZR3&3tuIrx*NPHYDc>G3A1F2C37AORS?V5}mnY{?U0cH`zGyW};du zo{i?{mAX-WY(xgLgZAua!*biByO)VhkIrN|j1Zk#bRy-7zmzG{;v=CkDf)NS{K z@%4n#)ni)(%ARQL`0+SG(-UW&c_|l9^F+}|snbcKXH`G#yS-|bCn`PVwt?K9>nS4!pK?MfB1*SoXna+Zq!N&QjC_>1rI@U)geSVkQTY z0b8W%i7xBEy6ax169?D*N=DMwa&Y^Y;fh4Djg#^pc*_;+DY{0 zsv9r> z&H94a-F93|s>!CmAbNXamw1|A85gT{tUob3$mbh|Z(qZoLx`c~o`uy91L@r*ezVGc5M0EN=$N8yIcxhzq|AGtILpRdv|8jA6ex{5<5G-bT`&= z5jeF-r}-!sS>KFPGJ?6VUVClC?+_Pj3eJUJdB#QCto0k*9ua-N`cvAa8(jQ|U%or` z2p2)Z?4Tx6LT)sw+F1qzR1NKi|5rbJzQLSvR%7r zmKPQ+dvUMpJ{OC}BUAU?B6js`);y8M#fgIJ-A7`$py+o6WIX3$L+yse<=2VckN@&9 z%Y%#WJ#NV>l*oI348#?ZzT>7v`Ju23F6!4k{FLazMUYmmRy*l;B5#)daUgwA%}2ij z$4Fnq)E4&_8Ip6C#0Gz~;^Lt7T$A;rf10)KW4ow@gJ~gx=CjMWXx!>|rT+~FV~0ay zrz&vqB+MtO<$&+zG5r~0oITA z?IUwAhT;Sk+%-QulUpO$hfAh&6(sxbIeOK*o&xP%+3zHg1Ja)|t(o=Ki;`K}g z^P>bO^->z;- z2=YS6hM7KFGQ9Bgzh8ZS)4Z^DLH5ARFfRmclu{jy^TNE3u^&^4$h!Boh5m26kb8c1 zu5%`N-zG|S^?fhA9eE`>n&bt|ErK{1A1@3pnG&uY>V?24%cCwVFRZ$Ja((hoFDO+B z>aNf7#)XkZ#-XaEE_}j#^cG`0_SnPaq#it7*{KAWDp*on&XX~ zjZ3;?#=TLgQ+!Bvo)5yU;|2pYd~he(-R7~04`$5K)G8wLuRiTKb6(O1lHs2oxV3nr z>9_`SYlAmJ&8cAzWPGqg?`O;PEN@h>-Ys+Y_r~P)Pec|G-cUdLf z+#?w!)7b8X#s89Q_Jn!k#8N}93OVoh*x&uJecmwILsL24<&C*N6jjF-`e3x*Zu|uq zH%(SqQ*_=3H~VvCesXw6XZ{;m&qHifE!XHQ z4_V__eQC;p!G_}-aLzk*$W~zxyF%kmj3?G0v>v{%U!Me$HSt6t@%IB@sK%oVIleqhm~Q)_d7g*+_kz?WH}l}XeyRPo1-|&w{dmfO zBwuJp9-UqkN$gNL(Bm`U3x&xR3e>N@V5YrTfB3X7w4OXndb5kH`x%i_CLcY(gLPq?rtd`_M(S)g#Ru{*HuB4^J(36A2jVq< zlX!T~ynH{E#Y5a~J64w?5BXgTO#>khUafiyEH!wLD1LtZ_=FG4<93SveD}el)#aPs zb@^cFw{0SpJP*mnwe$5Ic&M*jD8Dw7hZ$>+oNEr{!Oec-QqC#jk3Tl*rFA?w<*5~m zh&>zo;vJM4dAM;;$ze>{7iQkO@@|p&zp@9^WvzHv{GQi8X&MjF5_b#3ynN8?ly#IV z^ug@MpX?2FeXwZs_U)gZK6v=-;2m0)51uSJv*_X(ADDj7ofoI=gMk(2_js%#IMuK| zCcV-J#Y*>&EZFD+zHp!@eX0+>KdLdjYv%*KvbQ=uWZh!ru-3@!KJb!AI`yH^8>LrG z-nQ)aLB?mp)?3?rQ2yo!=ioITSnL(M9_}FPEN^cu|Lp@y-N0i9)p(#OuAg;Do8T&- zXu%p)9!|Eb<$JmCK%Y0AbNn(7qNX~MA);10fUhA*aU@;LPCEAjIJ zlPNMkdHC%w*ZpF>FA{a)>Ks@4VyWECx+y`vXpEXP+w!0bgqpsX zL&=quwe^L~cMDpSwl5xDO;uhuMsRodlFK1F525bf+3eLk*yGh!D)H-rnF9OXa3AC> zAKvM#=YwAVw96sW$@|4(lWvmV4a1u(K=S+DdDpaUb>84R%qw_I{M}Q&X*YYPH#~ID zuV^LnuVhDEYEF7%>4wslx^8cbF6;4O+xTFfsq*{hB!8<1ZKd)^KD+*#@^HJYHm<7p+Rk^~RXwla8QQ-bjnl+vHQ=4gH#k>;f`BE3HIT;k7qD z3Xaj7;83hZufvFm`;YTI&b>&25!>xBTF3W*s=5 z>Id_@EsBrx{2&tC-+1$m9}?TErg6jla6ZdlD*d`2ZZ_1=o$vbLbN;`KGiUs8_G6B{ zC)vMpbw~T_Vb1R>4p%Dt@FO{VrQudSm^C9V{jGd#d;aeI zh?)R_@|&0a?cif(M%lKX?R=cL?xeByAs?43D`}=>d?Y5YMnpb*ycCqQb_DTJwKuFm zdz}FC+n8IsWd$hBW%`>L31G-M5Xx-g!|eSAJJOOMTrIHc^(p~O7Wz4GZwRn9OJ>{4 z2|iX9=a04j$A{*n{R;hS1<=@&Fmy4NkE{!ovUWOr7)^^lb=;JX5Q~_hLV0<>A$Xx}@>$4&W{7mF4PK$HKb zw#%KIcg`}3vzHGE>oXmlxqPe+ed+r?iH~-t@9%^%0&Hllw^ZCLz_FSCg^HyG5K4V) z7WeQm{78+%UO?<_S{1%iD1a{|eY<+10CCIu0{<`s5Wif|hgv=wUsrh;l=CrK_hmf) z8XuD7Gj`{Z{X-gFJ0i(D)6QkT_^vNNC%<84Qh)&35uKd7Pl&zGrkFe55#q$v)4PqQ zi12mDM6OFsgv4<(m5SLS?0w(Qo=wJOH2!_VX(C)q-c&kOQG`X`c?#iggjhRu$NIzt zB4n2^4}LQgVf~D{v)Z;IoRhIlb5IdsJZizk7n&kyhV-Q=QAPOq#3OHkwFuDD5qAZN zV0M2m!zWdQn}v+dfKwvqI?4Uyt`b4!bjHI|YeiuCGan>-h!9uF-u8Z_2(K+?2*q+D z%re+!I!Q|evGu!Yg{C6>HxgKvGFODZ!oOkFtI7RKq@o{@`#W{-T|Ku>gbSDSRj(}( z;qCX2R~v{OC+;;z3a^Qv)c$L{=a2|NDfQ{|Lqr(5(>j0aSrOC{#>zPti9f7YkB!_A zq43aJorD4Meb3>JC_^#y0$XziG{tD_c-!}UofvX`_e;%KVld`iIaC!OhO%dD%50_> z%V8f_<|9T%^(~3R+r@AX&e}Fk5o0*5==0nQVzkbC=T=fKhVS2i;PD1A{!MyoGrLR- z4bB#G-C8mB&#JtulqtrfU5D~B&ysc0^EOUSBj3xF14l25p>)Qr`rU0YQV>_wH!Q|! zslbm_OZ*W#p>TEjR)4%T)xNF5_lK^{MEGMXfAq|7Y;@k@kHtM5PwKq=Q66^xqGhr_ zT$iT7fai~*NAt@zY51edK{_;2-yarsU(%b%XjID{lAP)f<;dtb#d-el);OsvE#VIy zX(}5>#PCz}`u?v?jJoR%P8Dy(XbH982KS3$-Y{ot^;Izx_=*2sG>Ng=@7=NzZGTJ$ zJ9bf@7y~29e}<2Xk>~KDF7cfh$DD3QXyl7A|7F-Py`A_`2k z1}`I4OXFE`yk^_U2%l6KnE&&OE0LRxZSD z{}U^t$UfgclgD=lh>-DnvsO9DyQPvo@y**r;3f@a_f95xexUMSgR%%hC-HsSWg+sd zbWCnu7T|N}og+Ff0yIpx-S6Ki!1&??>m?w7eZR|nai{=mSf^D=_yU~qkyWWo79jP- zEpXVe%(+!2epfCZ(=KQ^)m?^-U@#=jkCx=HM0zRDaj4iR8oVO#x&lL9<4 zpS-+`{4SfUXtL!1dDk?z6Z7>1P?^8AHu4D{>N~Vown+#OrL%~Ca<>30G+bB6dJ7QZ z5>ow*_#KFKeF}`Kq~RBV>mh2?*6V<>l*~f`E=;%Dl-9|JW0)G$qAq? zwc?1JrT|**QfF+R@zK$J@V09oA9b-=gKgzMYUN#>wIpx`oR(xcP zgqhZl`$48L>ajwv9}b;1OuD*+kJJ89UpH#-VYy@fmn9rNJWR#a>Li{9Ztpg@-zI+g z(wppmSAatYM-6^26kzqGktwIi`2#~A{chFpv7&^3s%WbKic6%Uu1X3Zct35HvZ4Tf zyV!BYlS#dom|7i46X4DY+e)=m0os$Aj~_luzH{Td*NX%gJ)F829s-zhYM++A6X5D` zPI7*>0L39vs>{gwJf52)C0>BslF#KQH<0<}I@@p(A3g2MQ^pSpkbdKrwhM`8?AY`- z$G-@WLQ&bckRrsnahs;4K|+`;9v_=oLF(82u7;w=Li{uM%$^h@ME%&q(rM}<(6zHN z`bnNB4E0|hCiU;sooOG1qe47hW9yRLF2ura>)6>+BK$sZxI2z2#E{jC?H?h88B=BR zr(*&rJo}@sR4Tv@N0U^!2mwaS_cLAw3XpF}@0q_t0PE%RqFpzU`aO5)$9nR<$bb&L ze}w|{rZTl1i65d3r3@8*kbF{ZSzRk9#P0W6RO9)iPCwtgV;PbFfhLVYX-?P4TbW&aI#*$_lR4&stAYN?zDC;BK7d>`uLfo z4j@!?!=dw=#dcwT+C5|ND*iJ**a|IYSnD%B*}7S-%ZVOs+xIka!F@jTTpMfjM@T+>?loRT?09Ma{oD_d?`Gm?k(`?V zt|HmKwS<>`XK>QXNPg5cu6tbTDa3Z0XKus4g;@T@F~N}Vr=-{S{USTUyN7Cp`w4Fc zt5Bb)6Wp~q&v!q1N{G`lsQ!`1g`lhFf99MOqNsZ1)W$_ZoK1d{qqjzgw4@1zg~YGb zfAVSkW#rtA78@il3Gr!e{tj(z5nP^r9)C#A-&ZQ3wub02x_J9%_b4)NQf{BbB>IXS zNl#>p5WmTO_!-e%zxv$e1|$Bb(~`tp38X&d7~fFmll^A9LYLc!;N`1Q`0;=UE2oI> z4`hgN#$FV#Ax(r>ji}s#3K5>MZw53bitxq4rBj>iZ%(v5EkXA8r`LK!2a2%n^dni( zaS;~2{I}7D>^FDXv}X#blY&EyrWzkbIIVg?O0rLc)yifm@8%HQ^!M|C#&S~Etu|5( z_+oU=GY8<#;ujbIgCOvg6~&#_*IILqZ(i#^FRy-X}!l! zy2Y>@du8``6VXBcHJ&Y=AbO%xkugDZN9USDyV}l+@lz1B|ISS@-aQCcz+R$5(vH8M z%n*Y+t0z1zSd2I9TU8t~ZvVWkyEI-5`Qe9@^b|3yFRRV^{DRnd;2h%w(P3AdOh;5* z#8{=^+{YlgL)$?yHiy*zx`B%euaWm$&pv5ZsU*hBH^J+wNxfh0dX0UlLxkD{xUVTA z`r?Y7_A65V&5TZth9rxici?C7Y;O@f_V=h}JBe`R`$vWyp^bS~5ug$u!O817@#39%$5{ms2TA^bG9Yi}+TLMwVUM@Z^m zvs3MH%^yN+IU$jBV3G(2uXX9g>4^UKe>|593q=_7%YLuER0Nx#;u9~)yqDYLHhUw2 z*I3C_DKCUL78+;1G){6CRq*v`yVxC4i_TTqY}@@a-ItPLGcO65kn9tNjV@ z?JbV3B6ad+#pJkwF+TpgeXcly)WOdICOLFp0rtQ-4|2MIYOjtW#2oUCB$mS@S2s~LbP49-P%p^Wu$&=|AER z*~NO4i$au*gqO89% zrumrYE00AByJJXS72EmpBXv6Ix3;*|@-u|6deZlBs8)#0HPfA%^Ms)C^#tjpPCZ&4 zD>5X%&v5p8-J2nTsLrNrd#Vs~&i~ohXeh*&joaVeAbu>%>svCF#A(sz={2XH3c)G- zdFwU72{WqXk@FrQYTJIzQX+OoId73lJuHMukJb%+kr1aYGIQMng_t+#;knDhLX>BR zF4_H2h!vx=OIpZ%%?|bte-K{K3$I=}gWxdv>P*Km!aIGrswK_@H$_q#v|e-(yXVKx zaY-WY$lJKQ<&O~VhWEU#k-n@s`Ih5l61P&%cdN??Kc$SP1bqn;p<(CHcO%lDIZm|n z=f;yhv&W$Fa2Dws>tm#Bvx#1%|9T=v#;^v76(@^CQ1N%27_Jpzo_ZfsuT6yLlNg~B>o(`W87ppBE;YG=I0K-6GHCL^TJIu5q`A9{9H@) zee3R|s05Pl1q)tSP9nIyoS5_}b*cynY9(1t4z7-+S8EfBSPBd#J7ILUw<<%N~#gw z-5t;EFijVs(n`5uCE?{U#Yw!cglAM$>OXXl-#sDo7o5LD`gX_9wn2j;G)}#;+iOgO zr#(^67{5h08@XAaK>D7Y#|0Pb2>&adf0h1-LHctSjf$D1e)v?}>aHZ`yw-TR!GuHl z?wpl(K1GYbyPo!NoJabmol?~&3Enr@Za+8MO@yO~O2v_6R9LU?ra}7scKS%Mo-W}* zokgPGLK3g>uPl{)Bu0A~71i&7cjFIwLPk$bms?*1YD{HahM zAK&X@NEe)$toEJs@!?lu{v+q@O{~#4C=%oU08mQ<1T6pn00;m803iTdIGE}G|NsC0 z|Ns9A02BarZ)|mKWiD=Tc>w?r002J#0000000}z)000000G*ooJJj#{hNBXdl(bl~ zjTvKXv+sjTE7C@kRFW-Ab}dw*WUXW^5xt{Lg-R$TQBv6|TPX?2Qqe}C@9Xm~eEl|# zW5&Fm=f1D&JdZ=tnvK@$HcL$jnG&k&;u^R!Kv$@#D|X}S8ffae`33|9?AX2CH^9a9 zf4^U`!#mKG_*PDch>Skw^ zPi)9EmSy~IXTbO*dxxM_j9VUqi{zTQSeo{;tn!Hn{)>cryZp4!KgRT6p68>WSimn( zG{DqtXC%`$@^S4RM>^i0_}std2^S7BaLuhUW|yM~T5jik{D{A=|5B&<>S;0V7q*jB z6U1n}@Hcs|nt|yLBK!9h37|9*V4^)&4A_2FXg5hBG={nJ$^J!tR;DGd`soX9G?R5{K=$_7%~qd)pc{Tm(Wna#u_?)b|m znN0YPCo)QEjIhDAKlzg<3uX>qNCT=AOrw##o9(r*xWiJQ7RW~h|1Ukrl!aS+MUgif zv=L7KI2mrq2Pb6ryUV^p94%OFCbv}w0jZ(Sr{-%RdE75``6(k@IPDwc8fJn?^8J1D zKhm*bFi-mFW;WJ|Gn}7$ad1j?c5c6+KJ0_eyf)DhpvgwrCV80-LKNmlK5J4(`c3|T zqZ=Fjs_`nxt|mCSW8n8gWj(x7-Bj;xFGN{p@{GDeR5+NH|F>s|j_&Zlwqq1M{L-;> zi@(N3YetS=-AP^O)jj)S^OTR+?RQgZ4~n4QdC$i7upyeV4wGGUg|Irl)%O=e2(^7< zhu3!)!dHJG?VO$-^v(5@zW0%EEy+@^f~E_~OWE8gkr4ap?2|vM@epL=sTsOXgz=;f zL%T=X=-70;+1ZGT0@DQD{G&I%?z4_w^6+fx${J05LO*(yYqX&*K6jW*^f z>Flde6u|UPOLgs4K87>>MrQR(P`HaeYCgin^Aq$n&$KwWzpXO#AzKHlrjespFqqii z);s%Mp)M|(IP>|H0vxLC_>MMRthkv|Pc9Y1wj{)Q*HRYRCZ@G}?Wg13mWhS0pU{vD z<+YjVd^l?;R$S<1f!jDg;&vtnwU4hDZjCjB%EB$)avWWFN@XwfA2J3n#YS;yJqdGp zq{WQ_K3+{XdpUZVm?Pi(#A*Q>rH14?<4&3o?z8<`OJSisdq%0(UTr9w|93oTP!|Cg z?|=1IWMgZ`HSfLAd|0;3%l!D95AVdCK2}~VoU*7MC_G3;P=2FEQx_fi!*&_FWh7WL zc4g%78ZxW`XD1aO;p4g2dPmw}A#7)~?EKZlhFW~(=v8|$&RF%&Q&giPTG@*om(77g z=;~}~wg|Gn*9R^5$HmBb`Qf2XE;bT|ci4$)eiTRdk>sd$FMdi7jy%#@fpoYqm*?EMR_aSA~E!oEK zVjgnH(dWadKi&7mZ#`5_-LXHZ#sEQ)hZE{bj6hL2+G!WdMt9}o&s#qkqeR8w1`M8vVgxNKVnjC`s`eY=?WrS_ok7u^J|4{iOrYcx^fa$}bA zYcBq~QoeHAJP}rBDBq3M<6(8P%8yy5TqwDB&q)ZQ}>{<{V5k3O@3NK)+SIM zioB4-A?99rZD-tFCRRMX;d7yafs)o-*XMjWLq*2Mq!p#t7`PM^8~0-i18kW&)5|OR)ap?K?w6 zo*d0uz4F0I5du!H{$ur;3F*BlnwlJKIEzje4|nN7d#+w9`zkT#|78?4e-R+N?w`&n z;@nnP$g6J5WMCkqHSo=RI!@3O>%#@wc%c6Bo3%s;Y6InxMl}Vi9!5^HkrjdB^ z79EE!O!|hu5h0&qiGg1X-lM zA@-bhDWvqBl_6UH?vl5-#lugz&%Z-=7{asT^xY@h88FL#8Mu>9$D(~ttd<$@@ug)! zM9xtogw2?~B)C)$*Z(ZbG34vQh`a0Xlqw;7)=xj?|3epFPt7knIhT&~SEf}%9TcP$ z7Y+5MiV^W!yXG2&j=Kj}W<)HefV?0eXDmRBdSzwy(IEj;$&q>AZnCgu-txqG>sVkN z&J!I-(7~I>^Lp=Fm_R@5mZWT0fJ@=-{t5vsj9X@ACgn@e_A@WSpuiXowv@HYtaWj0 zy;b2dDHd#87cIDKPUO>-O%?H4VrW=>+@D9B+oS5R@5d6j$Vpshp_(niZCP7u>SH>z z^K$Rqnn7^QzU$TV6b!+?s_!YkSOBtL(#F9P8YoR(qIZDU+Z+|^H1)F*tX;Y1Ps;}p zMijOxcAFbuhK82?@$~zVq@X=2NTC`Y9lE%Z-d|-6Su5}U7s)4 z#lhKL&V#E(c$q}oIB`=S2Yg$EA6+RpA1w34ag!b*ULT77V9&w&*1;x$s|b2B`)Y3x zJ)2jih(SIbNGaK><0HVkXZJ({SLmqGJO6g{wE*6K^TOL~^l_wZl^^X19d4&1b>6KI zVpu6MOlpTF^aiHuT+bxqgVy7mpON}Fk?C2=IZKAq`m?hHGYyemtC((eRBwEo_1_3i|)0s4?&-xw6={ zhJjo)wQK2{g&5JCs{QnxHc}IOx;L9>Ld}sIcxO@oL)n7TQJN042g1*KyeINvL2P;a z4;K7_89(+ah_UI?qE4TaTA#i<)cZ&+^2eG4Lpt4$4U!L$@dRTEKpWl*E5?Bs{SKhSgj$_K9^c{ z1nA&-W>LIXJ(0`r3uyDKNH}2n*Q4w_2Spx-qgzS|j@VJ)GsQ=OJkleL9RdlAW1DB2 z5&b`nlVNNTnK$`GgxKLPPhKn#pnvq%_~vDrxKwN%$ z2Kn0wKgQ^&e3T}oL-5t3wWE)y$y|6^I#et9i(wT$uD{NT3E|MwZ69nj&^2LOdcA~# zt@gRcI+cWID&*(>>?I-U;nvJo9r_Tv-PG=T!9c{c#^c9N@Nx0O$^QG>^&x9PJ-WP4 z2P$(uY_BBzYmZL-kN5v+Vor9o?i58naus%mOX zrZJ?ysx+B-2@(2^vx-X{J)k z5S}rt|C999h=OjD%Kv_(@$kie?&iW5I(SKS7Ab^lBc(jS%WHt(#wktF+r#wX?^ymJ z=>Z#GV^8~BNHs+B{Z;95JA_D&SaocuO$+DN(bk3#bC@qX{LAA28}0JjZZMbgu}!}1 zh4)J?M(=!=`KQD~oxM`m;!Pa<_sPd&Qkf3g?kb-8h#^{bM{8-cYC+fIEaj`55aGoS zgI9;~u%#@ZR_3HSHZ%^!Quc7*;SI%FJqdQr+rT}%j)DKSzsi5wsfT%oUH^M`iuk;{ z8!2_A3=Cen&ABzfLqw*2edc9?#~%)iOvbYiV&9w;`GW~(V>{;dDxxPBI4U;orQ!74 z4bH}ce5AC-Q}6F(A-mIip=J*kTMK9Q2o7kXYr*U>mkJ>kg>xztFSFrV_Rj6dPZ9V{ z>u#lNC3rgWTwHgGG3InBPuMwf(f+f}=fp;1yjG0oS&s-XHqS87fGNP=DbIKB9TwtJ z5TJP0UAp4tgYEvC^r7tvp$ax8R@F;Cy5?j zcBlFMhb%7Y78Nv0xzbUkvbgb@lomd7%zn;3uZ5}W&aY%9iorX+(^zA%7(-Ks-xzI| zpyKjrO%po~RwV6Iu z86vQL zL`QRy^fm!8->C^rBh3UKC@(+0;>TTWgs8e_^^2(J{aYI+6tZF7J==P;mEeFPqYjgE zObpdL4*%CnaMynp>vb&I(B0%{>pxQnig=l{m8l8A8~5v(Aq*rh2`SlonuQ;mqdp85 zYog!&S#??=2fxE^pZYk+1&eyXEUTOcp_+-N$d!k*;PuryRTLE4eptHLg@Q6AuP)20 zROI8OBW5$6n9! z9MFWfR-uCaG#WPlj9cp-C&cXOm2SJpY>01rQSaNbz_d2~Rq&Pz=La%b72}4O41Ktv z^}H@hlJh>jbJs+Gr}cjab&WyO^RW3wc)}HFOR8il53vDdiT$62nCmL*<@Zh>Y5JQt zSw7cAjNVrVi`zU5hJ7x${f~{LspdQG5nfrVGPN&8mIt+&iGc=x`Is?0I@5oxE{+t~ zjOY>k(zj(^Pw`JZ6tvu8?Y+Q39{iK;#!?V#>ofDvGXb6sYO5W3C_r&hZ^@OfJj__X zM8%l!#8bM9TyAl5MFBtUC1Xn)VAWu3yd6$|Jl{x-qKUzg`1xho?N9L6TsDckIVdi5@&x zO+PpPwFu3n*FXD|=_ANmZ+GHHMv#9$_s0gsmQ41jW=CWsSp|p_*`B4sKjgCxAUj02eoJ~f& zYUL{yg^Kgy=-%mCbOgMy2vFctz^vFkc27zJf%_`-`mSq1M?N%&0}gtanSg;SwGbyQ5XF%n&o? zPb6jf@L}63NIx-2#=(M3@};@jIISRg5Hw#4vCo6jqelgBTw-^0t3($zxtj+UJmsUM z=aDygL0QFQPU6-u+>U!;pEm>;fdMA>SD zpw+#((}Yfzf@PKr=6=`1_?kB08>#YO4iOh_bK6QbE7oSc`?#3OjhqwtU3@~ISz zC-8^Hw7Dp3wtaJ7MhnY|qYlp|voLSxx-$y?46I+0{$Re2HVoW%H-=xNK;=)Z`P4=_ ze0N>3n4f8co}d(cgJvvo}}c3kfj=bG~@z6FGCqd+g5;4`E|+bLNs5 z;9d?`_}0V-R|eMJiTBahzl>0Kd~#Th@L1)V+apV> zCD`T>U99OQ0=XgK)33+s*k*U9w2RI~S>(A&Ni!G6F3kES@FcwN`k;b8i2K;{&CpF+ zh%*CJn%;dO_>$Lzwn$)i_WgV12Ye_jm)zV)@VCr?f$&X6 zEGWJB^{~Lt0J&GEh&Oplpg;2F;NDmX_@_h0BX!u29(&X^dWJav#4~Dggh#|J^SgBE zfev)Tvqu7d=%Z;_1Ig(I6Vzjwb}yLv&~o^&XNjIJrj&$?ta#o zM`yv#d?s&-ks&n7e4j0m;$!SOGgHw?7i)ejHEX32J?eZ#QE5L73sqHL*Z;@Il)JQo z6=f2T?ydLF{3F0{l~Zo#Y6hxC=YKhUm4^)5qRzMKBCPNYIP-ER72kLnu5k}&2)f_) zDH}ArT7N$?^mO_iC*QW z4Lq?9(&bq*7#1PmNKbp?0jO(-7In}Pd zY8zZutecvCw($2Dbpwj=a+ubB?9``Hhp49EAxHPk$_Vak>|$Xw95jV{F(Zq)H6uU= zXSe-$=e_)=k&^cW1Pz5t->}r`kUMI7Ncl)aNrF{dj_l|^(8}mxEApo5 zp|>q3CEvIjca~RU~iWazp#VkZ^(doZ{d!wUnO4g9Y%Dkk#Iz1Au(DjEO z?-g9KEk30j82MIx@axzSjsN3W2xHZc_&K$m){N8Rn#(i$^S6+rqeC zPDpO)CZe#kgjXa_kt&`AlbhvVkdTAcWMJapEK!t*ogYf0E;NHiz4?L-nFxHX&flrh zyJ~^ru*3Yf$3hW#a$<8rS+3*qqf5q#6)HQ^=nLlQBs=xNQZHjoC)$+g*`qm_6%tlf z4wVx%dm>cxfu%m&$h3!7-lF{tYm_HDaq#pWB8sJOVZCDofPcq%o5zPNPtBOJv|;A2 z*j?TmD0@F|UyV&pa_9ro2^ag?4(uf|w7$qE;lm1iFrg4P#>-1VVUvW)r#Kk56!5_z zx%cdT#@By%`U`a?(deZq(hdwOLhTCU0~lwmcZr0V?ay{ihGr@peX9{((i zdRI2csC5WcUtOT+OFb|bxf|Tc&hUbx1oYLaaFEPsIg(i!D&f!4~#nJtUJLp zzq1tG8q%!t&6lqf!jj$coZKGn)x6PNK}2`8v_7oDw7jd2u^&FS>4I+sXL~rbOVG}C z)V}$<+X*y&$3*8qIh4f&y$O1h`Q&AN*x>i$j4YQqltVD?Y#$rS-q4J+H6Yy4SR7#( z$di0Hl8^e^N5@TUBW#3qH;)+kcCPU|`hWMIhBtOa*k+?sn&?f~wF&g)Wt!;Jr}j9xJQOYGhW+hXai=d|icVEF z8Kyga+#Ti`j}>G?>ee@prelG0U&+8Z=&_L1>{Z7J<6GF3pM zHb~?FyItCe1kt_3+{`-p%81=@euy$IO4eu|rczb3#?N&BS;r=vZ4JFmIx24Hrh;j2 zgL?7#e6d#uV%o7jOXL@jHdA>un*sMjo@n)DRTGj=e(vwz9P%Oui%VPmx?Rx7M(tgfe`!{;db`bF#6c`{Ltkgv9T~szSWHnXWVJUI zal~9EQ}TgQbCw9Vh$(CMKrLa~0tIF~W9B0tX7#$tA;-2<&^XmQQQ&g9rrC)otp zp4Tfad={mx1|VJaY@P=1~RS}1w94q*ajB4Y{w_%3^rj17^7YL|IPHbD!;p=4o zXl$q*Ybb|RpGZ>U&aY6bo5pr`WZj@q8B#jo^tJh~%L7s=KNO%|z4fRl#X?zm#SwG= zK7d^40P0uh37iUgVhX2Q|%VhIEVF zR^pV*l=z0b%79m|z(Ru*smEJ(kd(g$uM5yB_xqis%XmD(xo0-hZ+U+Bt}31uk4%`v zC!MlEA{6_|N3#0TjHuQD^$<2m)88$6_l2YQTW+44GQf|BcEEC4UsTLh;0RdljSw@1 z^iPv!XdN2207d5uYtIE=m2MQCaTZWcXY)$-7tUMZpiIVxb$Uf5Za5^P6|AR8@fo}n z)I(v(R{8$Sy2>$9>wGDh?(6%HB(W@7j%zt3sIMvqCC9-zl`Rjn4hq5K+ZQt)PZ<=e z@raXhY(+ab0%o#mA3h7Ox8_{?NQ-vKTKBm{pyMRfqol0m?QAaY^>P}c-s_SiRgIui z+Nwnoa%ZEachr~_D#KTq8~Q2oQ4JPx!B-Vscv$-DEEFnm_17~gVqx;3&!4vO-tDvD zGm0DtRP-6Q{8?#w07WxL9?1o2`p=7XPCTVjLnI&e+L@0ydj^(|c{XU^(7d}gjYbD9 zLLF-!$=H2m#`5leR|hG`Gfv}|#dcN3wF{rQX%6F?ybjVOtqn!1?B+3&8ut8(tyZkL z_Q!ZWa%j1^D`fLq-KDy_k3v5;GT3?{MUoU*(^rtBf{jy(5_kv2W=M~Lh3lGi5U}@`UuG0Bgs`X_7sda~~05l;giLYLJ@#u_P^J81-mSC6N& zXMc#L{c+y7iRlv?YbsSwky=VqJTq$sTTpfY9_LOg++|^-HZu?DL>fXS30BaC zDX*5@qgT$pgHcWMiAdXtMBaNF?^I(EM^oH?PS9N7r-G3le`uFHA(* z*D?$*`pVPQ8g--C$J#Wc+z@`ZCX-s5$c_+u5wPyQtzjMNO9tux6L*NQTvPN1><67@Y`9ID3RC1a5O-i`^UsO_kK%OlKkK8)t^>D!B!7hr({q}xbD;dj zR^)+wjHs=8`qvPN1+U2b`@|4KN-BPiWBHveeY&{dF_iVV`aQ&>*Kj4CRv)`yE43;` zX4a;mMS}_55vF4xW&Pgd>c5{M$e(84?4IR>Miyzq4a80j<4|9|z-*GK8D5q#`ehT? zY%xu+&G2~HQd-MGFB5*XE_5JtCf-8N4|J;PKrjj@ZXct8ec%KB%iS!4HFmCu^CH5p zTR$@g6H({p>vF7c5`+?c)X z?&GC$g!0aw7(mKXV!`#5g{@d@MEUEshRqtJC=i%`bo}zJ_RyrP*}@1Oqm2$u?#!Q; zP}QQApZsmh`@9ft7gp$bPBMCt*8?#6*(4|nP2|SHD?}V{N}|u#?9$BqW%rN1r)s#% z^_;yGe{X=5+hBhxxt-7Sc^rhl6j3fcaCY1uPa2iEf`QC==c|C>^_nrx>?pDI`D-;Q zcCSPA%EaI8?4qzQ=IA6j_WS5}qQsRfRSW*lBRzqsGqOk&1DEwBR{f_~Calx94HDG= zd%7Y&Ech!CLL(}PYuLq7?(3#IEOgGNgQHdNamjZlOBR}d2yEfNB!aW0Un5(YRg^x; z+}50k6?iSALgU@!(Se(5dKz3)a@cLmOQEnpfMQ;YV8di+gxapY4C`kO{}TqBRh+FW zt{Lsr{9!!O%o4_W<#60E8+U1S!kvzT)0ET$31#|gFQdDt5WjXfl)Ii~OU_YWa3uF& z7;UDTHL7!h2_EqEFX9*W={S_O{_uqRlWH9AG}LTok)lf~RJ~?UQ!;?!A=wOhH~HFW z+#*>Ww?`%5b-P|n#?~~%F3HN?=;ydsqwuNfGVkN|&)YZ@-!b*M6)+`YLP=BIqg(p*KZY^uZ5QVo5=leqpq5B z7Lei9{5>>k%Hmv8H+5zz(VN9Zfd};;VSDUoTK)GGfW}ITG%Q_6-8KFmtc5d%aM~P( zcRVOzMPE#kE|YX37PCOUt$g^H6c?w$IzI!Ebj;`!<3Jkn)A(xm;5XJ*+>JmFvnDJr zbU95CBo?Zv-DEwl0#C97^6_KEU-4_PW7Nt=%7G6%@-~ytz22doxj`pBVAPCxYz?0R zU9<3r%Ag+2xRleQ98x+cXm4!2UsV{jj+zI;gP635QSvwyYBUdpGd36ll52HR?*Zt; zs#fPJZts30x5=p96A})&>aq?jFew(Q4yz8EJxZbW#8}LO0~mGzRw3|2;M?Svr?J3- z<#o5SapH>>XDWsbZ9IU zAJqtQM?)no6NjeJOe`4$-MyjlF0 z;YNq6I_lGcV8U|hZf6gNJ@;Cpr7(LUj{al_Ma6}jh4~Y$NsAGB&aG_3NjxY__;-?J zMM)F^QWz%Wl&2wUfco^n^ZL7{&HzDq%-_}8MdG39i%o+&Ux`no-7AAesW%6Sq9B5$ zQJ-DHbm8i#g7@?s*F`Vw#kKwRI$b_3;;|ejWZ1y!>I%86sEhtdBJG;w>Iq$eMCX3O z2W;=*xN`EH{8f2cu8}E05Tle~0qjMg`&u99Cy$%r{o%KAFHrg=JJ6kS%t`*+Kg>d2 zkJ4uaWK+kWiDXx`z>;i!xEUolW4j=Raz5&2{_iAl%G)P*#}l1&|HPmJd6jAK>G8TV z^cc@l4Z5ZoUmE*Yuu(`&6EI%`&s~b9x2fOW5iTSzY3t8ySB;5$Yo`0vJL>%(`XJoz zz?=9t2iV?{Yqy7~o`dX)0Zyrthf7awR-}jFKDSY7ApVKY!7b%-n4&uWlIzIFW3=ty znAG^!AfkOdVPV1Js)-rfmQus7xFUuw4v5+C;*Jv+Qs@$vQz|EF@&&DFEXD=W3eOSu z>G#X+suh2%Mm$~tzV3Wz-beaBGtx)4=$5-XWu)^bVlyQJiGT8g{GxRFh$ZDa&Nhau zFkSxoraAY;3=O2RIB9+{NsIr&KTK$xcxho{zZFI;nTD}?c72n=pGca++x5mu*jW|8 z__>Q2V}4|)vztN0FK(bAxfhv-;Ryn27egbtGV}j-euK3XH-BB~Pd>Su-P(qz+Ew^P1lm0+3X|$=J z{j*X~05S&)>aF$R*0g12dTjzcmi2-}wFY(GRq>X!HXnSshg!R36L)cO_t}&-v$%|_ z!GtFgTd}Q%kfC#O^iOx0Da(M50LfBryCs^y>=n5kcS1_6Bh`E+_?2 z_t{I~H0Jbn$R`V1B9{CVsxs_7EN=L`M@0)9GcKvs zlwzTvYa2e(3o%nbrjRBzZ15g(LGe>?>*RAv3W>{UvLqV8ESM?l7zwL{n=x$jFVSD< zgKw%zMV)FVB?Ww4`_9!XKO`U<_7aWe27PHf#Y30pKL^btnvfsxaDjDGR7wu-Yg+H6RxJy#GKR5XtqU%xc=!j4`q=R)RWziw3)d3xY7yEz;tq}cXb;6YT{h`Alq^vnqg z57J8GJ3LBm5Z{5S?qeDX@h3gbd>J;fl^2(%FzmJ#^DWXdlDv;xJpq3}QlXn|szS*) z91zEx@d~n7_-R6Q0SgPH%>;DKf!GISlaVX#~*RC%evaLyzCBduhl#H9}U_PIV=nVjm-$G(=d{uiv>^%^?i zTTn!4esngH&f=nfo&9&`phzj*D#?a4OS0vX@$1S-G^o7}6AR&1;B@kh-Mx|+6;AY% zpe&Zy`Rzc0OT}SY!gUM%6E`kbX0)khck(7D9aFK3JgJfwM#>Tx+oailH>lXD*9JH! zUxfaw(?1M2DU=>MlujskfG1>FE@En7#CX;UCFqWwN=QwSuk)21fcSf{poAcxpEp~% z1BFqIoabkWsq%%HX~(Le#;_%vwS9kiahvKhk(m8BVZBfOjTfIAD?{0Bh#weZe%9EE>qsug|aqk39w} z0YkZ_csi?1hX~;Hpe_~$XH#KKtv1MRbjU04fLUCHR5Jco=tPEUo2AzuosI_rB)K{L z=iNGLN8S>f0*)S$jRb2N#qgR5QMKm+B}}!IxET|7ye(zDd~@vz9Gs5238+lg#vB8F z4uAzsbmga(kv22_%>uD4sh-MK+Yk1bujAa$NS@@%faKgxqL4w(E#lb%XA*M zoO9~JfP`)-!$BPkwlO`loD=d&8q@v&#B$wyon_cMc4eRT^=p69z~B7&G|o|sLz8AM z1ZfmCKuXr`+^9}ZROSYP+y;=o7-zKuA8FVxDP)f``@tX7N14~_h5E((_IGx<4VhR= zli4E#f;SmXgc~Yp?%1vTHTXl2wPXGg&M^^*OPM}^p;WywATGKJnr~WE{`x~H1rcoL zo}ULrdQlB0%|cmzvSEx^4c_Dyghekv?pPo%QnCUdklg>29Z^;Zlu=u@|JYWf>k+9AMsW)FaHlDwpa z*lrIt#^v$6T2~c+llSyQ``z=)y(_tyOu0y0HVf~kFa6L7QLFbfwW^JbwgStnW+Gfv zb`mJEZKx5-=+hg+hEs#U-`Xg>mcJqE`L;)Irop@*5-?JfWW-hTmhukvZf{%Ak3*M+ z4`8cT=cz@eqGK2+K3iTgFK??!KFnyO;m*lo616G`wUzt|5KAmMSXOY0=e-YbUKfQi zomJ#ATW9@#^srm-6MzeyC-Ki0@@t*?u6ge+oYnV1A!oqg zBgx(Q?D5?;=JgF5Ks+vJ2I`@7+HJXGK7W-hWe`6a*FH^j zH{~GV7QcW4nS7~6f3!;cv(9V?Y6IhgN4h4j$#EJi?mB3~!z*-CcqnV~Z6C4LI_LF1 zRF9vYy{c(GhLhM;&UL+WG$Ux^#k8@Obw1ZV7T%-{18R}%dptP+a1VfmgHQ2BiGk^ies6`U`kxiKW0 zA#?e*QH23|v`~WqqyOrK4Csa%PhZ(3_|(2%>1gF(g|^4gZTWrlFSAJMhx- z4`TR+{`k!dtu}hIav|b+d}Q0R#!`lE$8ggGm{VQ(WeYJ0IgH~%W~SC8dr?J2>=r z*#vH#_Hr6Yw>GyaCiYv?@$AM8j%Ze0JXd41cq&;zI!aO#Mo5G^8#u_E0=Kf+m+{gXdFC~eD`?% zW?`PnwmlOK=&@TR{H-`P`toa#3?`E)(@Ez~V6q2DRFm9+07>)BZ+_{=_#jcY#|D54 zhSg$0d!4Lfzx8pQSSYSEP1KObOC>2($r=7LY6Bu$_wKwpvYDNSZ)EGdu;>8Dh~+@t z5Y}?>`hL`x;zHaEYd!T*+;28jTuDeYAhLI{T7;71VLHdKf{D^c-{`Gmfl|H!H1l)d z536{8@7&29n&-UlR7odmKv0wgNw5$?9!1H~)!1I}1<45}%jzt+KRJG1z0#E5+Hf5q12c!l~#9MmxKI+;oKw+Zf2%V?pIc~ znYWpsnephqN3;I6Op@!Gzn z!R;dX@WG;tPwZ4$$$m0yRvCbd-cF7&8J|^dOZ+I?$^K7qDjJ6Xh0>gD{V3d-_ZkZU zHoiGLN#{KDF%P@vW1iX{-!Qjtjw2exB@k0$KHI(#4fiD5XcR4#eT}c`USCTb4eEp8 zf-+(tWpQ*aM@CXfFFiA{|F)pe=!qfaQVM<`(D&1;C10OsSQEOEfSog+6}&Vr$aU7& zt<0q1F4ZGNMxTOYJ2!ykf{;36mojYH`%CZN%yr(>qko;BM8ynaoo<0yqRTi#{XAdl zo1gfVvst}?lR1G)-c18z8f4C8MhWXfVj08LNy!>`WDf&m)0=h?G(^@sWAhyGxol2k z_wwjY7cSoZ)s^T*+Xut#4z5(`0=$PZFCtLw!eb&C-p}z<{->3wX{N^v3J7k zR^|#6jDr4D%=Roux>7&2FX%rYW?im^h<=OgDs4Y-R!$Xvyn8_=j;fE+tYq0T1}?9W z&)sLVtp1#^ZJ7S9GA!GcCM&gabeWI04gJAzPufUqmLH%8eVM=|;jXY&Nf;Ad)5mPI zlF^yuJ7?e96Viue(^*{!H>g=P#A)7$3j>&KSY+l97`fiFk@oqptycMRGsTgcOwZY` zgv8j+u2#-*r%-x$rR1CM;`VOH(UUxna!IBgCCbL)Lb7DwW62?~q~b(ggb5Vn`~6QM z$KEExv(@8BZ8D*)d-K&NO_M6AwKsnY?u}iOUG+4kHn)zh2&F9T^}cl}*KJf;eA?Ob z7nYm9BG@tg zcvQG~1^r>3_)AA(z6bVZU1gFqUcS@cKw$20I$MrLwSujd^=-O$qxGoU>6vfdlV?rw zS*3;G<}2vfMfUzs&_fV>b%1E+e~y}+dE>Vs^WU2vmBHX|88Jh4AhF&)drY^EQSwx- zP3ydnB>oq>3`_k^5sGPm{5haKEFlKXk8>Ab~o<2h*R%;`o#t{X(H70D2d zY*2?u=J$=1jWDm%1wQ8hEP#TU-~pBmO%4s{dv*h^*KlA<^(%x86%>gW{5aU&jC*W6 z2R?OD7hra`1$s-3llcsBp6DbCBcWd;R=cg>Z`!sWBhO@G4BwMpfB79gY0KBrZl(5% z;i9v!9_*-`+iiAa%?86{r9Z*5;OrDhgt4~UFXrGYhT}qj>%xaS5~oOg1s#pTb+NbH zc51dUHo(N4HFFY1Li8)ouy_T=5hF_}%2Ux2Vf6r#*l@nzxE9AugHy=h@p9 zr7(+^P8pgs8um2ZDd;1xJ?CXs@}ZggJp1-42q$HHa^GRHq^~lLK23Q#%wYB`Y;NW8 zXB!AFqy*mG_L=@5qax<2{)CC+;3$BJG?yM27n%*hEjP#5FbS}EC>=ukoQ-H2LX^*P zrGJuyy^LIw-VoGzR|@cPhX+DAGo``JPZI(!2!TQK}CF6#hnqS3E^%rUY& zmGq8)Hs613W-86BA=oo4rsH%z(qTGPpMe1_xiQ-Zc|N2JcDAmC!9FE;qBeuiJV>vy zXaFW(i;8x1kgnEegDbS{I&IQ7njR};!eFeIZ~9o$IjXNbNx?*L?Z4>VnB?hZ*#92Sb6=ZaQV=>J*qrRP>sJ?SFj4kiCbG0t6{=B2s!WnK1TkBRLg(h>c zGw124A-Ps9Jm8|c1|xp&p7jWNGUKy{C4kd4I7nD$F2!60%hlaL#WtO~zf8GcZCdeD zsg1t!1rK}Rzow_td4CKaducnKn3;ip*axOD%+6c6KY`ofY=}`jITx#)iE(?@g4)v< zaemYjT{0{mJz#=lKNM8My;8d!R46?^3hjnp}%V1jjfa9k*me zjN9@6Isui~p-ie!4!`)Wu~9N>z+rYm;y0--I8+PW(!BbPBV~3MrBV_!6V{b~-dlK> z|LUMWY$d*xssE#H_f*BvgT zR8wD&0TRsw*CpX~K^wwJCQ>uu8E4PiM`gK>pO|2n-bJwp?~VLJD%RUo)PIs)h5zTs zf2n2PU%x*7r{~~kX8)fY*#948357H`{ str | None: + try: + return subprocess.check_output( + command, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except Exception: + return None + + +def _read_toml(path: Path) -> dict[str, Any] | None: + if tomllib is None or not path.exists(): + return None + try: + with path.open("rb") as handle: + return tomllib.load(handle) + except Exception: + return None + + +def _cpu_model() -> str | None: + if sys.platform == "darwin": + return ( + _run_cmd(["sysctl", "-n", "machdep.cpu.brand_string"]) + or _run_cmd(["sysctl", "-n", "hw.model"]) + or platform.processor() + or None + ) + if sys.platform.startswith("linux"): + cpuinfo = Path("/proc/cpuinfo") + if cpuinfo.exists(): + text = cpuinfo.read_text(encoding="utf-8", errors="ignore") + for pattern in (r"model name\s+:\s+(.+)", r"Hardware\s+:\s+(.+)"): + match = re.search(pattern, text) + if match: + return match.group(1).strip() + return platform.processor() or None + if sys.platform.startswith("win"): + return os.environ.get("PROCESSOR_IDENTIFIER") or platform.processor() or None + return platform.processor() or None + + +def _total_memory_bytes() -> int | None: + if sys.platform == "darwin": + raw = _run_cmd(["sysctl", "-n", "hw.memsize"]) + return int(raw) if raw and raw.isdigit() else None + + if sys.platform.startswith("linux"): + meminfo = Path("/proc/meminfo") + if meminfo.exists(): + text = meminfo.read_text(encoding="utf-8", errors="ignore") + match = re.search(r"MemTotal:\s+(\d+)\s+kB", text) + if match: + return int(match.group(1)) * 1024 + return None + + if sys.platform.startswith("win"): # pragma: no cover + try: + import ctypes + + class MEMORYSTATUSEX(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + + status = MEMORYSTATUSEX() + status.dwLength = ctypes.sizeof(MEMORYSTATUSEX) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) + return int(status.ullTotalPhys) + except Exception: + return None + + return None + + +def _cargo_release_profile() -> dict[str, Any] | None: + cargo_toml = _read_toml(_ROOT / "Cargo.toml") + if not cargo_toml: + return None + profile = cargo_toml.get("profile", {}).get("release") + return profile if isinstance(profile, dict) else None + + +def git_info() -> dict[str, Any]: + """Best-effort git metadata for reproducible benchmark artifacts.""" + return { + "commit": _run_cmd(["git", "rev-parse", "HEAD"]), + "dirty": bool(_run_cmd(["git", "status", "--porcelain"]) or ""), + "branch": _run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]), + } + + +def runtime_info() -> dict[str, Any]: + return { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "python_version": sys.version.split()[0], + "python_implementation": platform.python_implementation(), + "python_executable": sys.executable, + "platform": platform.platform(), + "system": platform.system(), + "release": platform.release(), + "machine": platform.machine(), + "processor": platform.processor() or None, + "cpu_model": _cpu_model(), + "cpu_count_logical": os.cpu_count(), + "total_memory_bytes": _total_memory_bytes(), + } + + +def build_info() -> dict[str, Any]: + return { + "rustc": _run_cmd(["rustc", "-Vv"]), + "cargo": _run_cmd(["cargo", "-VV"]) or _run_cmd(["cargo", "-V"]), + "cargo_release_profile": _cargo_release_profile(), + "rustflags": os.environ.get("RUSTFLAGS"), + "cargo_build_rustflags": os.environ.get("CARGO_BUILD_RUSTFLAGS"), + "maturin_flags": os.environ.get("MATURIN_EXTRA_ARGS"), + } + + +def package_versions(*names: str) -> dict[str, str | None]: + versions: dict[str, str | None] = {} + for name in names: + try: + versions[name] = importlib_metadata.version(name) + except importlib_metadata.PackageNotFoundError: + versions[name] = None + return versions + + +def file_info(path: str | Path) -> dict[str, Any]: + file_path = Path(path) + data = file_path.read_bytes() + return { + "path": str(file_path), + "size_bytes": file_path.stat().st_size, + "sha256": hashlib.sha256(data).hexdigest(), + } + + +def benchmark_metadata( + suite: str, + *, + fixtures: list[str | Path] | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + metadata: dict[str, Any] = { + "suite": suite, + "runtime": runtime_info(), + "git": git_info(), + "build": build_info(), + "packages": package_versions("numpy", "ferro-ta"), + } + if fixtures: + metadata["fixtures"] = [file_info(path) for path in fixtures] + if extra: + metadata.update(extra) + return metadata diff --git a/ferro-ta-main/benchmarks/profile_runtime_hotspots.py b/ferro-ta-main/benchmarks/profile_runtime_hotspots.py new file mode 100644 index 0000000..bdd6fcf --- /dev/null +++ b/ferro-ta-main/benchmarks/profile_runtime_hotspots.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import argparse +import json +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import numpy as np + +import ferro_ta as ft +from ferro_ta.analysis.features import feature_matrix +from ferro_ta.analysis.options import iv_percentile, iv_rank, iv_zscore +from ferro_ta.data.batch import compute_many + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata + + +def _time_min(fn: Callable[[], object], rounds: int = 5) -> float: + fn() + samples: list[float] = [] + for _ in range(rounds): + t0 = time.perf_counter() + fn() + samples.append(time.perf_counter() - t0) + return min(samples) * 1000.0 + + +def _naive_correl(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(x), np.nan, dtype=np.float64) + for end in range(window - 1, len(x)): + x_window = x[end + 1 - window : end + 1] + y_window = y[end + 1 - window : end + 1] + mean_x = float(np.sum(x_window)) / window + mean_y = float(np.sum(y_window)) / window + cov = float(np.sum((x_window - mean_x) * (y_window - mean_y))) + std_x = float(np.sqrt(np.sum((x_window - mean_x) ** 2))) + std_y = float(np.sqrt(np.sum((y_window - mean_y) ** 2))) + denom = std_x * std_y + out[end] = cov / denom if denom != 0.0 else np.nan + return out + + +def _naive_beta(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(x), np.nan, dtype=np.float64) + for end in range(window, len(x)): + start = end - window + rx = np.array( + [ + x[idx + 1] / x[idx] - 1.0 if x[idx] != 0.0 else np.nan + for idx in range(start, end) + ], + dtype=np.float64, + ) + ry = np.array( + [ + y[idx + 1] / y[idx] - 1.0 if y[idx] != 0.0 else np.nan + for idx in range(start, end) + ], + dtype=np.float64, + ) + mean_x = float(np.sum(rx)) / window + mean_y = float(np.sum(ry)) / window + cov = float(np.sum((rx - mean_x) * (ry - mean_y))) / window + var_x = float(np.sum((rx - mean_x) ** 2)) / window + out[end] = cov / var_x if var_x != 0.0 else np.nan + return out + + +def _naive_linearreg(series: np.ndarray, timeperiod: int, x_value: float) -> np.ndarray: + out = np.full(len(series), np.nan, dtype=np.float64) + xs = np.arange(timeperiod, dtype=np.float64) + sum_x = float(np.sum(xs)) + sum_x2 = float(np.sum(xs * xs)) + for end in range(timeperiod - 1, len(series)): + window = series[end + 1 - timeperiod : end + 1] + sum_y = float(np.sum(window)) + sum_xy = float(np.sum(xs * window)) + denom = timeperiod * sum_x2 - sum_x * sum_x + slope = (timeperiod * sum_xy - sum_x * sum_y) / denom if denom != 0.0 else 0.0 + intercept = (sum_y - slope * sum_x) / timeperiod + out[end] = intercept + slope * x_value + return out + + +def _old_iv_rank(iv: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(iv), np.nan, dtype=np.float64) + for idx in range(window - 1, len(iv)): + win = iv[idx - window + 1 : idx + 1] + lower = float(np.nanmin(win)) + upper = float(np.nanmax(win)) + out[idx] = 0.0 if upper == lower else (iv[idx] - lower) / (upper - lower) + return out + + +def _old_iv_percentile(iv: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(iv), np.nan, dtype=np.float64) + for idx in range(window - 1, len(iv)): + win = iv[idx - window + 1 : idx + 1] + out[idx] = float(np.sum(win <= iv[idx])) / window + return out + + +def _old_iv_zscore(iv: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(iv), np.nan, dtype=np.float64) + for idx in range(window - 1, len(iv)): + win = iv[idx - window + 1 : idx + 1] + mean = float(np.nanmean(win)) + std = float(np.nanstd(win, ddof=0)) + out[idx] = np.nan if std == 0.0 else (iv[idx] - mean) / std + return out + + +def build_hotspot_report( + *, + price_bars: int = 20_000, + iv_bars: int = 50_000, + window: int = 252, +) -> dict[str, Any]: + rng = np.random.default_rng(2026) + close = 100 + np.cumsum(rng.normal(0, 1, price_bars)).astype(np.float64) + high = close + rng.uniform(0.1, 2.0, price_bars) + low = close - rng.uniform(0.1, 2.0, price_bars) + iv = rng.uniform(10.0, 40.0, iv_bars).astype(np.float64) + ohlcv = { + "close": close, + "high": high, + "low": low, + "volume": np.full(price_bars, 1000.0), + } + + rows = [ + ( + "rust_kernel", + "CORREL", + lambda: ft.CORREL(high, low, timeperiod=30), + lambda: _naive_correl(high, low, 30), + ), + ( + "rust_kernel", + "BETA", + lambda: ft.BETA(high, low, timeperiod=5), + lambda: _naive_beta(high, low, 5), + ), + ( + "rust_kernel", + "LINEARREG", + lambda: ft.LINEARREG(close, timeperiod=14), + lambda: _naive_linearreg(close, 14, 13.0), + ), + ( + "rust_kernel", + "TSF", + lambda: ft.TSF(close, timeperiod=14), + lambda: _naive_linearreg(close, 14, 14.0), + ), + ( + "python_analysis", + "iv_rank", + lambda: iv_rank(iv, window), + lambda: _old_iv_rank(iv, window), + ), + ( + "python_analysis", + "iv_percentile", + lambda: iv_percentile(iv, window), + lambda: _old_iv_percentile(iv, window), + ), + ( + "python_analysis", + "iv_zscore", + lambda: iv_zscore(iv, window), + lambda: _old_iv_zscore(iv, window), + ), + ( + "ffi_grouping", + "compute_many_close", + lambda: compute_many( + [ + ("SMA", {"timeperiod": 10}), + ("EMA", {"timeperiod": 12}), + ("RSI", {"timeperiod": 14}), + ], + close=close, + ), + lambda: ( + ft.SMA(close, timeperiod=10), + ft.EMA(close, timeperiod=12), + ft.RSI(close, timeperiod=14), + ), + ), + ( + "ffi_grouping", + "feature_matrix", + lambda: feature_matrix( + ohlcv, + [ + ("SMA", {"timeperiod": 10}), + ("ATR", {"timeperiod": 14}), + ("ADX", {"timeperiod": 14}), + ], + ), + lambda: { + "SMA": ft.SMA(close, timeperiod=10), + "ATR": ft.ATR(high, low, close, timeperiod=14), + "ADX": ft.ADX(high, low, close, timeperiod=14), + }, + ), + ] + + results: list[dict[str, Any]] = [] + for category, name, fast_fn, reference_fn in rows: + fast_ms = _time_min(fast_fn) + reference_ms = _time_min(reference_fn, rounds=1) + results.append( + { + "category": category, + "name": name, + "fast_ms": round(fast_ms, 4), + "reference_ms": round(reference_ms, 4), + "speedup_vs_reference": round(reference_ms / fast_ms, 4), + } + ) + + results.sort(key=lambda row: row["fast_ms"], reverse=True) + total_fast_ms = sum(float(row["fast_ms"]) for row in results) or 1.0 + for row in results: + row["share_of_suite_pct"] = round( + float(row["fast_ms"]) / total_fast_ms * 100.0, 2 + ) + + return { + "metadata": benchmark_metadata( + "runtime_hotspots", + extra={ + "dataset": { + "price_bars": price_bars, + "iv_bars": iv_bars, + "window": window, + } + }, + ), + "results": results, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Profile ferro-ta runtime hotspots.") + parser.add_argument("--price-bars", type=int, default=20_000) + parser.add_argument("--iv-bars", type=int, default=50_000) + parser.add_argument("--window", type=int, default=252) + parser.add_argument("--json", dest="json_path") + args = parser.parse_args() + + payload = build_hotspot_report( + price_bars=args.price_bars, + iv_bars=args.iv_bars, + window=args.window, + ) + + print( + f"{'Category':<16} {'Case':<18} {'Fast (ms)':>10} {'Ref (ms)':>10} {'Speedup':>10}" + ) + print("-" * 70) + for row in payload["results"]: + print( + f"{row['category']:<16} {row['name']:<18} {row['fast_ms']:10.2f} " + f"{row['reference_ms']:10.2f} {row['speedup_vs_reference']:10.2f}x" + ) + + if args.json_path: + path = Path(args.json_path) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nWrote JSON results to {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/benchmarks/results.json b/ferro-ta-main/benchmarks/results.json new file mode 100644 index 0000000..834428c --- /dev/null +++ b/ferro-ta-main/benchmarks/results.json @@ -0,0 +1,16097 @@ +{ + "machine_info": { + "node": "mac", + "processor": "arm", + "machine": "arm64", + "python_compiler": "Clang 20.1.4 ", + "python_implementation": "CPython", + "python_implementation_version": "3.13.5", + "python_version": "3.13.5", + "python_build": [ + "main", + "Jul 11 2025 22:26:07" + ], + "release": "25.3.0", + "system": "Darwin", + "cpu": { + "python_version": "3.13.5.final.0 (64 bit)", + "cpuinfo_version": [ + 9, + 0, + 0 + ], + "cpuinfo_version_string": "9.0.0", + "arch": "ARM_8", + "bits": 64, + "count": 14, + "arch_string_raw": "arm64", + "brand_raw": "Apple M3 Max" + } + }, + "commit_info": { + "id": "d40e68b5913e74fc5cd5d89106d7649a318ae98c", + "time": "2026-03-23T22:20:33+05:30", + "author_time": "2026-03-23T22:20:33+05:30", + "dirty": false, + "project": "ferro-ta", + "branch": "main" + }, + "benchmarks": [ + { + "group": null, + "name": "test_speed[Overlap/SMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/ferro_ta]", + "params": { + "indicator": "SMA", + "library": "ferro_ta" + }, + "param": "Overlap/SMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002488583995727822, + "max": 0.0002727418002905324, + "mean": 0.00025733753995154984, + "stddev": 7.2994548482285454e-06, + "rounds": 20, + "median": 0.00025416259959456513, + "iqr": 7.108400313882222e-06, + "q1": 0.0002522458002204075, + "q3": 0.00025935420053428975, + "iqr_outliers": 3, + "stddev_outliers": 5, + "outliers": "5;3", + "ld15iqr": 0.0002488583995727822, + "hd15iqr": 0.0002705499995499849, + "ops": 3885.946839269058, + "total": 0.005146750799030997, + "data": [ + 0.00027131679962622, + 0.0002705499995499849, + 0.0002727418002905324, + 0.00025694179930724204, + 0.0002594418008811772, + 0.0002518918001442216, + 0.0002521834001527168, + 0.0002668333996552974, + 0.00025926660018740224, + 0.0002529334000428207, + 0.00025400839949725197, + 0.00025274999934481456, + 0.00025166660052491354, + 0.00025154999893857167, + 0.0002523082002880983, + 0.00025287500029662623, + 0.00025529999984428284, + 0.0002488583995727822, + 0.00025431679969187824, + 0.00025901660119416193 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/talib]", + "params": { + "indicator": "SMA", + "library": "talib" + }, + "param": "Overlap/SMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00031141660001594574, + "max": 0.0003478250000625849, + "mean": 0.0003291053999419091, + "stddev": 1.2718672114499328e-05, + "rounds": 20, + "median": 0.00032801660054246893, + "iqr": 2.5487598759355067e-05, + "q1": 0.0003152041012072004, + "q3": 0.0003406916999665555, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.00031141660001594574, + "hd15iqr": 0.0003478250000625849, + "ops": 3038.540237190005, + "total": 0.0065821079988381825, + "data": [ + 0.00033810000022640454, + 0.0003363665993674658, + 0.00034470819955458867, + 0.0003472165990388021, + 0.0003432833997067064, + 0.00034092499990947547, + 0.00033324999967589977, + 0.0003404584000236355, + 0.00032829160045366734, + 0.0003478250000625849, + 0.0003208916008588858, + 0.00031372499943245203, + 0.00032774160063127057, + 0.0003273249996709637, + 0.000313841798924841, + 0.00031444160122191535, + 0.00031141660001594574, + 0.00031323339935624973, + 0.00031596660119248554, + 0.0003230999995139427 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/pandas_ta]", + "params": { + "indicator": "SMA", + "library": "pandas_ta" + }, + "param": "Overlap/SMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003763165994314477, + "max": 0.00046844179887557403, + "mean": 0.0004044970797258429, + "stddev": 2.0394951574226647e-05, + "rounds": 20, + "median": 0.0004011416000139434, + "iqr": 2.1820899564772855e-05, + "q1": 0.00039067080069798976, + "q3": 0.0004124917002627626, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0003763165994314477, + "hd15iqr": 0.00046844179887557403, + "ops": 2472.205734285579, + "total": 0.00808994159451686, + "data": [ + 0.00039130820077843965, + 0.00041310840024380016, + 0.00042389159934828056, + 0.00040489999955752867, + 0.00039299159980146217, + 0.00039243339997483415, + 0.00038816679880255834, + 0.00038508339930558576, + 0.00038789159880252554, + 0.00040650000009918587, + 0.00039003340061753987, + 0.0004033831995911896, + 0.0004118750002817251, + 0.00040874159894883634, + 0.00039810839953133834, + 0.00043040839955210686, + 0.00046844179887557403, + 0.0003763165994314477, + 0.00039890000043669717, + 0.00041745820053620266 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/ta]", + "params": { + "indicator": "SMA", + "library": "ta" + }, + "param": "Overlap/SMA/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007623667988809757, + "max": 0.0008528915990609675, + "mean": 0.0007977570797083899, + "stddev": 2.307634920379365e-05, + "rounds": 20, + "median": 0.000794916698941961, + "iqr": 2.8224900597706415e-05, + "q1": 0.0007794958000886254, + "q3": 0.0008077207006863318, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0007623667988809757, + "hd15iqr": 0.0008528915990609675, + "ops": 1253.51441614976, + "total": 0.015955141594167797, + "data": [ + 0.0008070082010817714, + 0.0008126583998091519, + 0.00080654159974074, + 0.0008014082006411627, + 0.0008084332002908923, + 0.0008286249998491257, + 0.0008382833999348805, + 0.0007623667988809757, + 0.0008528915990609675, + 0.0007958167989272624, + 0.0007977834000485017, + 0.000779474999580998, + 0.0007795166005962528, + 0.0007935667992569507, + 0.0007940165989566595, + 0.0007855581992771476, + 0.0007918083996628411, + 0.0007750499993562698, + 0.0007703750001383014, + 0.0007739583990769461 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/tulipy]", + "params": { + "indicator": "SMA", + "library": "tulipy" + }, + "param": "Overlap/SMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003195166005752981, + "max": 0.0004660999999032356, + "mean": 0.0003386604299157625, + "stddev": 3.601720613971838e-05, + "rounds": 20, + "median": 0.00032367079984396696, + "iqr": 1.2750101450365048e-05, + "q1": 0.00032198749904637224, + "q3": 0.0003347376004967373, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0003195166005752981, + "hd15iqr": 0.0004089334004675038, + "ops": 2952.81028329391, + "total": 0.00677320859831525, + "data": [ + 0.00032366660016123203, + 0.0003232250004657544, + 0.0003231084003346041, + 0.00032139179966179655, + 0.00032056659983936697, + 0.00032161659910343585, + 0.00033194999996339903, + 0.0003355918001034297, + 0.0003204084001481533, + 0.0003230500005884096, + 0.0003195166005752981, + 0.000327741599176079, + 0.0003329999992274679, + 0.0004089334004675038, + 0.0004660999999032356, + 0.0003223583989893086, + 0.0003236749995267019, + 0.0003414416001760401, + 0.0003338834008900449, + 0.0003519833990139887 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/finta]", + "params": { + "indicator": "SMA", + "library": "finta" + }, + "param": "Overlap/SMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008491332002449781, + "max": 0.0009218667997629382, + "mean": 0.0008720020701730391, + "stddev": 2.31616081672388e-05, + "rounds": 20, + "median": 0.0008626875001937151, + "iqr": 2.4441698769805953e-05, + "q1": 0.0008554708008887246, + "q3": 0.0008799124996585305, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.0008491332002449781, + "hd15iqr": 0.0009189249991322868, + "ops": 1146.7862682958553, + "total": 0.017440041403460782, + "data": [ + 0.0009063582008820958, + 0.0008491332002449781, + 0.0008630334006738849, + 0.0009026832005474717, + 0.0009218667997629382, + 0.0008623415997135453, + 0.0008782666001934559, + 0.0008573999992222525, + 0.0009189249991322868, + 0.0008552166007575579, + 0.0008557250010198913, + 0.0008519000009982846, + 0.0008589666002080775, + 0.0008722915998077951, + 0.0008503000004566275, + 0.0008500500000081957, + 0.0008566667995182798, + 0.0008699500001966953, + 0.0008774084009928629, + 0.0008815583991236053 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/ferro_ta]", + "params": { + "indicator": "EMA", + "library": "ferro_ta" + }, + "param": "Overlap/EMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003657499997643754, + "max": 0.0004116167998290621, + "mean": 0.00037906500001554376, + "stddev": 1.3700847541789766e-05, + "rounds": 20, + "median": 0.000372070799494395, + "iqr": 2.1737499628215996e-05, + "q1": 0.0003675416999612935, + "q3": 0.0003892791995895095, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0003657499997643754, + "hd15iqr": 0.0004116167998290621, + "ops": 2638.069987888606, + "total": 0.0075813000003108755, + "data": [ + 0.000399183199624531, + 0.00039443319983547556, + 0.0004116167998290621, + 0.000388308399124071, + 0.0003848834006930701, + 0.00036864180001430216, + 0.0003684083989355713, + 0.0003692749989568256, + 0.00036692499998025596, + 0.00036872500058962034, + 0.0003662415998405777, + 0.0003677417989820242, + 0.0003657499997643754, + 0.0003673416009405628, + 0.0003662084011011757, + 0.00039025000005494804, + 0.0003824082014034502, + 0.0003882250006427057, + 0.00039186659996630623, + 0.0003748666000319645 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/talib]", + "params": { + "indicator": "EMA", + "library": "talib" + }, + "param": "Overlap/EMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00035595840017776936, + "max": 0.0003876499991747551, + "mean": 0.0003612466701451922, + "stddev": 7.761028101936687e-06, + "rounds": 20, + "median": 0.0003584249003324658, + "iqr": 2.9958995583001307e-06, + "q1": 0.00035732080068555665, + "q3": 0.0003603167002438568, + "iqr_outliers": 3, + "stddev_outliers": 2, + "outliers": "2;3", + "ld15iqr": 0.00035595840017776936, + "hd15iqr": 0.00036749999999301507, + "ops": 2768.1916060238846, + "total": 0.007224933402903843, + "data": [ + 0.0003876499991747551, + 0.00036100840079598127, + 0.0003589334010030143, + 0.00035962499969173224, + 0.0003583333993447013, + 0.00035844160011038183, + 0.00035595840017776936, + 0.00035769160022027793, + 0.0003577581999707036, + 0.000356774999818299, + 0.00036749999999301507, + 0.000376158399740234, + 0.00035930000012740493, + 0.0003584082005545497, + 0.00035834160080412404, + 0.0003588749998016283, + 0.00036355839984025806, + 0.0003569500011508353, + 0.0003567834006389603, + 0.0003568833999452181 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/pandas_ta]", + "params": { + "indicator": "EMA", + "library": "pandas_ta" + }, + "param": "Overlap/EMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00042723320075310767, + "max": 0.0004673832008847967, + "mean": 0.0004417950101196766, + "stddev": 1.147674609880261e-05, + "rounds": 20, + "median": 0.0004438334006408695, + "iqr": 1.8295799964107584e-05, + "q1": 0.0004306291993998457, + "q3": 0.00044892499936395326, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.00042723320075310767, + "hd15iqr": 0.0004673832008847967, + "ops": 2263.493197284218, + "total": 0.008835900202393531, + "data": [ + 0.00044064999965485183, + 0.00044757499999832363, + 0.0004453750007087365, + 0.000446324999211356, + 0.0004673832008847967, + 0.0004438500007381663, + 0.00045429160090861844, + 0.00045542500010924413, + 0.00045598340075230225, + 0.0004502749987295829, + 0.00044486679980764164, + 0.00044381680054357276, + 0.0004318666004110128, + 0.0004305999987991527, + 0.0004301167995436117, + 0.0004274999999324791, + 0.00043065840000053866, + 0.00042827500001294536, + 0.00043383340089349077, + 0.00042723320075310767 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/ta]", + "params": { + "indicator": "EMA", + "library": "ta" + }, + "param": "Overlap/EMA/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006342834007227793, + "max": 0.0006823666000855156, + "mean": 0.0006480024899792624, + "stddev": 1.2900239512939045e-05, + "rounds": 20, + "median": 0.0006415750009182374, + "iqr": 1.6129199502756797e-05, + "q1": 0.0006394750002073124, + "q3": 0.0006556041997100692, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0006342834007227793, + "hd15iqr": 0.0006823666000855156, + "ops": 1543.2039466885415, + "total": 0.01296004979958525, + "data": [ + 0.0006416500007617287, + 0.0006435666000470519, + 0.0006562333990586921, + 0.000640741600363981, + 0.0006401917999028228, + 0.0006404165993444622, + 0.0006399416000931524, + 0.0006374415999744088, + 0.0006528999991132877, + 0.0006549750003614462, + 0.0006415000010747462, + 0.0006367249996401369, + 0.0006390084003214724, + 0.0006372666000970639, + 0.0006342834007227793, + 0.0006477331990026869, + 0.0006598249994567596, + 0.0006619334002607502, + 0.0006823666000855156, + 0.0006713499999023043 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/tulipy]", + "params": { + "indicator": "EMA", + "library": "tulipy" + }, + "param": "Overlap/EMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003557334013748914, + "max": 0.0003751833995920606, + "mean": 0.00036206417986250016, + "stddev": 6.561072585034807e-06, + "rounds": 20, + "median": 0.000359241699334234, + "iqr": 1.2783400597982076e-05, + "q1": 0.00035694159960257825, + "q3": 0.00036972500020056033, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0003557334013748914, + "hd15iqr": 0.0003751833995920606, + "ops": 2761.941267925942, + "total": 0.007241283597250003, + "data": [ + 0.00037119180051377044, + 0.0003711165991262533, + 0.0003710083998157643, + 0.00036844160058535635, + 0.00036245819937903435, + 0.0003577417999622412, + 0.0003585415994166397, + 0.00035703319881577047, + 0.0003560333992936648, + 0.00035657500120578334, + 0.000356850000389386, + 0.0003560750003089197, + 0.00036051659990334886, + 0.000357933399209287, + 0.0003557334013748914, + 0.0003604249999625608, + 0.0003712249992531724, + 0.0003751833995920606, + 0.00035994179925182836, + 0.0003572583998902701 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/finta]", + "params": { + "indicator": "EMA", + "library": "finta" + }, + "param": "Overlap/EMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000689658199553378, + "max": 0.001083741600450594, + "mean": 0.0007726833099150099, + "stddev": 8.93923449144952e-05, + "rounds": 20, + "median": 0.0007445041999744717, + "iqr": 7.084590033628051e-05, + "q1": 0.0007233415999507997, + "q3": 0.0007941875002870802, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.000689658199553378, + "hd15iqr": 0.001083741600450594, + "ops": 1294.1912775493927, + "total": 0.015453666198300197, + "data": [ + 0.0008400999999139458, + 0.0008639000006951392, + 0.001083741600450594, + 0.0007684500000323169, + 0.0007024331993306987, + 0.0008199250005418435, + 0.0007044165991828777, + 0.0007341999997152015, + 0.000689658199553378, + 0.0007108415986294859, + 0.0007475250007701106, + 0.0007581334008136764, + 0.0007568500004708767, + 0.0008623831992736087, + 0.0007489583993447013, + 0.0007414833991788328, + 0.0007325418002437801, + 0.0007195915997726843, + 0.0007270916001289151, + 0.0007414416002575308 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/ferro_ta]", + "params": { + "indicator": "WMA", + "library": "ferro_ta" + }, + "param": "Overlap/WMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00025695820077089595, + "max": 0.00047544999979436396, + "mean": 0.0002729729200655129, + "stddev": 4.796588843959603e-05, + "rounds": 20, + "median": 0.000260033300583018, + "iqr": 8.429199078818816e-06, + "q1": 0.00025843750045169146, + "q3": 0.0002668666995305103, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.00025695820077089595, + "hd15iqr": 0.00047544999979436396, + "ops": 3663.367046665296, + "total": 0.005459458401310258, + "data": [ + 0.0002590750009403564, + 0.00026510000025155024, + 0.0002613415999803692, + 0.0002672917995369062, + 0.00026758340100059287, + 0.00027533319953363387, + 0.00025720840058056637, + 0.0002597915998194367, + 0.0002577999999630265, + 0.0002602750013465993, + 0.000257466800394468, + 0.00025939999904949217, + 0.00027409159956732766, + 0.000262941799883265, + 0.00047544999979436396, + 0.0002664415995241143, + 0.00025712500064400956, + 0.0002595833997474983, + 0.00025919999898178504, + 0.00025695820077089595 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/talib]", + "params": { + "indicator": "WMA", + "library": "talib" + }, + "param": "Overlap/WMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003557415999239311, + "max": 0.0003807000000961125, + "mean": 0.0003645608299848391, + "stddev": 7.5480632496527174e-06, + "rounds": 20, + "median": 0.000363025000115158, + "iqr": 1.1937499220948655e-05, + "q1": 0.0003578250005375594, + "q3": 0.00036976249975850806, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0003557415999239311, + "hd15iqr": 0.0003807000000961125, + "ops": 2743.026451968487, + "total": 0.0072912165996967815, + "data": [ + 0.00035924160038121046, + 0.0003632584004662931, + 0.00037172500015003607, + 0.00037037500005681067, + 0.0003557415999239311, + 0.00035777500015683473, + 0.000356774999818299, + 0.00035627500037662687, + 0.00036194999993313106, + 0.0003594334004446864, + 0.00035579160030465575, + 0.00036279159976402295, + 0.00035787500091828407, + 0.0003676584005006589, + 0.00036914999946020545, + 0.0003710249991854653, + 0.0003664999996544793, + 0.00036825839924858883, + 0.0003807000000961125, + 0.0003789165988564491 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/pandas_ta]", + "params": { + "indicator": "WMA", + "library": "pandas_ta" + }, + "param": "Overlap/WMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00042777499911608177, + "max": 0.0004674666008213535, + "mean": 0.0004393791499751387, + "stddev": 1.2557183278979184e-05, + "rounds": 20, + "median": 0.0004339832994446624, + "iqr": 1.8975099374074467e-05, + "q1": 0.0004301124004996382, + "q3": 0.00044908749987371266, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.00042777499911608177, + "hd15iqr": 0.0004674666008213535, + "ops": 2275.9386740508344, + "total": 0.008787582999502774, + "data": [ + 0.0004674666008213535, + 0.0004597833991283551, + 0.0004461000004084781, + 0.00045649160019820554, + 0.00042963320011040195, + 0.0004351750001660548, + 0.0004320250009186566, + 0.0004351749987108633, + 0.0004314584002713673, + 0.00042879999964497986, + 0.0004294418002245948, + 0.0004320416002883576, + 0.00042777499911608177, + 0.0004369250003946945, + 0.0004520749993389472, + 0.0004350999995949678, + 0.0004305916008888744, + 0.000432866599294357, + 0.00042929159972118216, + 0.00045936660026200114 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/tulipy]", + "params": { + "indicator": "WMA", + "library": "tulipy" + }, + "param": "Overlap/WMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00033674160076770934, + "max": 0.0004601918000844307, + "mean": 0.00038051916992117187, + "stddev": 2.984033943215161e-05, + "rounds": 20, + "median": 0.0003779165999731049, + "iqr": 4.1299900476588e-05, + "q1": 0.00035841249919030813, + "q3": 0.00039971239966689613, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00033674160076770934, + "hd15iqr": 0.0004601918000844307, + "ops": 2627.9884932135205, + "total": 0.007610383398423437, + "data": [ + 0.00040290839970111845, + 0.0003571415989426896, + 0.0003442750006797723, + 0.00033674160076770934, + 0.0003419249987928197, + 0.00035652500082505867, + 0.0003596833994379267, + 0.0003919999988283962, + 0.0004601918000844307, + 0.0004175584006588906, + 0.0004001331995823421, + 0.00039929159975145013, + 0.00041231679933844133, + 0.0003711915996973403, + 0.00036294180026743563, + 0.0003753500001039356, + 0.0003856417999486439, + 0.00038048319984227417, + 0.0003711916011525318, + 0.0003828916000202298 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/finta]", + "params": { + "indicator": "WMA", + "library": "finta" + }, + "param": "Overlap/WMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.10562689179932931, + "max": 0.11277605819923338, + "mean": 0.11009632960987802, + "stddev": 0.0018945288742794161, + "rounds": 20, + "median": 0.11020489169968642, + "iqr": 0.002985625099245229, + "q1": 0.10849363750021439, + "q3": 0.11147926259945962, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.10562689179932931, + "hd15iqr": 0.11277605819923338, + "ops": 9.082954931771663, + "total": 2.20192659219756, + "data": [ + 0.10780974180088379, + 0.10791333340021084, + 0.10814943340083119, + 0.10914368320081849, + 0.10562689179932931, + 0.10826710840046871, + 0.11011473320104415, + 0.1115168584001367, + 0.11237966659973608, + 0.11277038339903811, + 0.11102183339971816, + 0.11152200839860597, + 0.1111434584003291, + 0.10872016659996006, + 0.11015570839954307, + 0.11016197499993723, + 0.1102478083994356, + 0.11144166679878253, + 0.11277605819923338, + 0.11104407499951777 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/ferro_ta]", + "params": { + "indicator": "DEMA", + "library": "ferro_ta" + }, + "param": "Overlap/DEMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004083334002643824, + "max": 0.0005255915995803662, + "mean": 0.000448509600100806, + "stddev": 3.4895833864869e-05, + "rounds": 20, + "median": 0.00043836669938173144, + "iqr": 3.9987399941310276e-05, + "q1": 0.00042388340007164514, + "q3": 0.0004638708000129554, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0004083334002643824, + "hd15iqr": 0.0005255915995803662, + "ops": 2229.6066790437535, + "total": 0.00897019200201612, + "data": [ + 0.00048548340128036217, + 0.00040860000008251517, + 0.0004083334002643824, + 0.0004162999990512617, + 0.0004525249998550862, + 0.0005125250012497417, + 0.0004272665988537483, + 0.0004340750005212612, + 0.00042745000100694595, + 0.0005255915995803662, + 0.0004382083992823027, + 0.00044912500015925616, + 0.0004578000007313676, + 0.0005075250010122545, + 0.00044449179986258967, + 0.0004385249994811602, + 0.0004699415992945433, + 0.00041865840030368416, + 0.00042629180097719654, + 0.00042147499916609375 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/talib]", + "params": { + "indicator": "DEMA", + "library": "talib" + }, + "param": "Overlap/DEMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005306416001985781, + "max": 0.0006560168010764755, + "mean": 0.0005942396000318694, + "stddev": 4.0130681060291094e-05, + "rounds": 20, + "median": 0.000600125000346452, + "iqr": 6.35748998320196e-05, + "q1": 0.0005610000996966846, + "q3": 0.0006245749995287042, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0005306416001985781, + "hd15iqr": 0.0006560168010764755, + "ops": 1682.8228881857913, + "total": 0.01188479200063739, + "data": [ + 0.000627358398924116, + 0.0005872334004379809, + 0.0005994999999529682, + 0.0006217916001332924, + 0.0006346416004817002, + 0.0006188081999425777, + 0.0006460834003519267, + 0.0006082250009058043, + 0.0006007500007399358, + 0.0005985415991744958, + 0.0005899499999941326, + 0.0006056333993910811, + 0.0005445168004371226, + 0.0005338833987480029, + 0.0005330665997462347, + 0.0006362250001984649, + 0.0005344418008462526, + 0.0005306416001985781, + 0.0005774833989562467, + 0.0006560168010764755 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/pandas_ta]", + "params": { + "indicator": "DEMA", + "library": "pandas_ta" + }, + "param": "Overlap/DEMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006160666001960635, + "max": 0.0007623582001542673, + "mean": 0.0006757008001295617, + "stddev": 3.274947737574488e-05, + "rounds": 20, + "median": 0.0006722000005538575, + "iqr": 2.2699999681208196e-05, + "q1": 0.0006601708002563101, + "q3": 0.0006828707999375183, + "iqr_outliers": 4, + "stddev_outliers": 4, + "outliers": "4;4", + "ld15iqr": 0.0006532084007631056, + "hd15iqr": 0.0007371165993390605, + "ops": 1479.944969442475, + "total": 0.013514016002591233, + "data": [ + 0.0006534833999467082, + 0.0006699666002532468, + 0.0006774499997845851, + 0.0006842999995569698, + 0.000701191600819584, + 0.0007623582001542673, + 0.0006532084007631056, + 0.0006689581990940496, + 0.0006814416003180668, + 0.0006744334008544683, + 0.0006692499999189749, + 0.000660125000285916, + 0.0006779082003049552, + 0.0006974999996600673, + 0.0006242582006962038, + 0.0006160666001960635, + 0.000660216600226704, + 0.0007371165993390605, + 0.0006761918004485779, + 0.000668591599969659 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/tulipy]", + "params": { + "indicator": "DEMA", + "library": "tulipy" + }, + "param": "Overlap/DEMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00030955840047681705, + "max": 0.0003939749993151054, + "mean": 0.0003568879098020261, + "stddev": 1.8662767517296157e-05, + "rounds": 20, + "median": 0.00035432499935268426, + "iqr": 2.3145900195231694e-05, + "q1": 0.00034634580006240865, + "q3": 0.00036949170025764034, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.000342024999554269, + "hd15iqr": 0.0003939749993151054, + "ops": 2802.00021501071, + "total": 0.0071377581960405225, + "data": [ + 0.0003731834003701806, + 0.00035428339906502516, + 0.0003788249989156611, + 0.00037883320037508384, + 0.00035030839935643596, + 0.00035329999955138194, + 0.00036377499927766623, + 0.00030955840047681705, + 0.0003939749993151054, + 0.0003828749991953373, + 0.00034643340040929615, + 0.00035436659964034335, + 0.00035481660015648233, + 0.00034290820040041583, + 0.0003462581997155212, + 0.000342024999554269, + 0.0003658000001451001, + 0.00034458340087439865, + 0.00034667500003706666, + 0.0003549749992089346 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/finta]", + "params": { + "indicator": "DEMA", + "library": "finta" + }, + "param": "Overlap/DEMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.001858624999294989, + "max": 0.0020374832005472855, + "mean": 0.0019142966698564123, + "stddev": 5.050497054266134e-05, + "rounds": 20, + "median": 0.0018938041997898837, + "iqr": 6.678350109723397e-05, + "q1": 0.001877595799305709, + "q3": 0.001944379300402943, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.001858624999294989, + "hd15iqr": 0.0020374832005472855, + "ops": 522.3850700607487, + "total": 0.03828593339712825, + "data": [ + 0.0018836749994079582, + 0.0018985167989740148, + 0.001858624999294989, + 0.0018733165998128243, + 0.0018890916006057523, + 0.0019450668012723326, + 0.0019436917995335535, + 0.00196179159975145, + 0.001881874998798594, + 0.001994991599349305, + 0.0019980249999207444, + 0.0018661834008526057, + 0.0018668584001716227, + 0.00190394160017604, + 0.001924824999878183, + 0.0020374832005472855, + 0.0019114417998935096, + 0.0018717749990173616, + 0.0018875581998145207, + 0.0018872000000556 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/ferro_ta]", + "params": { + "indicator": "TEMA", + "library": "ferro_ta" + }, + "param": "Overlap/TEMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004330915995524265, + "max": 0.0004908165996312164, + "mean": 0.00045130038975912614, + "stddev": 1.581528155914103e-05, + "rounds": 20, + "median": 0.00044890839999425227, + "iqr": 2.0104100258322433e-05, + "q1": 0.0004386249995150138, + "q3": 0.00045872909977333625, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0004330915995524265, + "hd15iqr": 0.0004908165996312164, + "ops": 2215.8190480042194, + "total": 0.009026007795182523, + "data": [ + 0.0004908165996312164, + 0.00047767499927431345, + 0.0004514082000241615, + 0.00045110840001143514, + 0.00047804999921936543, + 0.0004338250000728294, + 0.00043775820086011663, + 0.00045989999925950543, + 0.00044329160009510816, + 0.0004521249997196719, + 0.00045839999947929757, + 0.00045905820006737487, + 0.0004436834002262913, + 0.00044442499929573385, + 0.0004522666000411846, + 0.0004393165989313275, + 0.0004379334000987001, + 0.0004351665993453935, + 0.00044670839997706935, + 0.0004330915995524265 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/talib]", + "params": { + "indicator": "TEMA", + "library": "talib" + }, + "param": "Overlap/TEMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007298084005014971, + "max": 0.0008072834010818042, + "mean": 0.0007689820702944417, + "stddev": 1.7571385956548363e-05, + "rounds": 20, + "median": 0.0007671542007301468, + "iqr": 1.4983299479354281e-05, + "q1": 0.0007594375005282927, + "q3": 0.000774420800007647, + "iqr_outliers": 3, + "stddev_outliers": 6, + "outliers": "6;3", + "ld15iqr": 0.0007507581991376356, + "hd15iqr": 0.0007973916013725102, + "ops": 1300.420437133342, + "total": 0.015379641405888832, + "data": [ + 0.0007675084008951672, + 0.0007949582010041923, + 0.0007298084005014971, + 0.0007507581991376356, + 0.0007578834003652446, + 0.00075875820039073, + 0.0007509332004701719, + 0.0007839084006263875, + 0.0007723081987933255, + 0.0007728500000666827, + 0.0007697499997448177, + 0.0007631168002262712, + 0.0007668000005651265, + 0.0007601168006658554, + 0.0007661249997909181, + 0.0007759915999486112, + 0.0008072834010818042, + 0.0007973916013725102, + 0.000772183200751897, + 0.0007612083994899876 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/pandas_ta]", + "params": { + "indicator": "TEMA", + "library": "pandas_ta" + }, + "param": "Overlap/TEMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008261583992862142, + "max": 0.0008528584003215656, + "mean": 0.0008346420999441761, + "stddev": 7.677758042930123e-06, + "rounds": 20, + "median": 0.0008312792000651825, + "iqr": 8.141699800035007e-06, + "q1": 0.0008299833003547974, + "q3": 0.0008381250001548324, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0008261583992862142, + "hd15iqr": 0.0008528584003215656, + "ops": 1198.1183312786204, + "total": 0.016692841998883524, + "data": [ + 0.0008404084001085721, + 0.0008477249997667968, + 0.0008299166001961567, + 0.000831291799840983, + 0.0008313831989653408, + 0.0008302083995658904, + 0.000831266600289382, + 0.0008303499998874031, + 0.0008528584003215656, + 0.0008459249991574324, + 0.000835041599930264, + 0.0008323667992954142, + 0.0008268999998108483, + 0.0008298499989905395, + 0.0008300500005134382, + 0.0008358416002010926, + 0.0008459584001684562, + 0.0008308084012242034, + 0.0008261583992862142, + 0.0008285334013635292 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/tulipy]", + "params": { + "indicator": "TEMA", + "library": "tulipy" + }, + "param": "Overlap/TEMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00033141659951070325, + "max": 0.0003504667998640798, + "mean": 0.00033672791010758374, + "stddev": 5.043303324796754e-06, + "rounds": 20, + "median": 0.00033457909958087837, + "iqr": 6.85420091031116e-06, + "q1": 0.00033303329983027654, + "q3": 0.0003398875007405877, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00033141659951070325, + "hd15iqr": 0.0003504667998640798, + "ops": 2969.75679764859, + "total": 0.006734558202151675, + "data": [ + 0.000334024999756366, + 0.00033510820067021995, + 0.00033141659951070325, + 0.000333933399815578, + 0.00033990000083576887, + 0.00034619999933056534, + 0.0003408000004128553, + 0.00033389999880455433, + 0.0003504667998640798, + 0.00034159180067945273, + 0.0003324834004160948, + 0.0003361666007549502, + 0.0003329749990371056, + 0.0003381666014320217, + 0.00033235820010304453, + 0.00033309160062344745, + 0.00033987500064540653, + 0.00033494160015834495, + 0.0003329418002977036, + 0.00033421659900341184 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/finta]", + "params": { + "indicator": "TEMA", + "library": "finta" + }, + "param": "Overlap/TEMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.003263733199855778, + "max": 0.003538933400704991, + "mean": 0.0033302312298474136, + "stddev": 7.008348779448429e-05, + "rounds": 20, + "median": 0.003300212499743793, + "iqr": 0.00010047500036307637, + "q1": 0.0032764832998509515, + "q3": 0.003376958300214028, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.003263733199855778, + "hd15iqr": 0.003538933400704991, + "ops": 300.2794493779997, + "total": 0.06660462459694827, + "data": [ + 0.003385516599519178, + 0.003538933400704991, + 0.003415758399933111, + 0.003414074999454897, + 0.003317399999650661, + 0.003341191599611193, + 0.0032945416009170004, + 0.0032899832003749907, + 0.0032728666003094984, + 0.0032989415994961746, + 0.0032720000002882444, + 0.0033684000009088777, + 0.0032674833986675368, + 0.0032911165995756163, + 0.0032704333993024194, + 0.0033014833999914115, + 0.003280099999392405, + 0.0033872749991132878, + 0.003333391599880997, + 0.003263733199855778 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/T3/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/T3/ferro_ta]", + "params": { + "indicator": "T3", + "library": "ferro_ta" + }, + "param": "Overlap/T3/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004604000001563691, + "max": 0.00048169159999815746, + "mean": 0.0004666525000357069, + "stddev": 6.308517724311604e-06, + "rounds": 20, + "median": 0.0004655208002077415, + "iqr": 7.120799273252509e-06, + "q1": 0.0004616375001205597, + "q3": 0.0004687582993938122, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0004604000001563691, + "hd15iqr": 0.00048169159999815746, + "ops": 2142.922195688404, + "total": 0.009333050000714138, + "data": [ + 0.00048169159999815746, + 0.0004673750008805655, + 0.0004727917999844067, + 0.00047851679992163556, + 0.0004654584001400508, + 0.0004655832002754323, + 0.0004626000009011477, + 0.00046797499962849545, + 0.0004604000001563691, + 0.00046175000024959443, + 0.00046119160106172785, + 0.0004621168001904152, + 0.00046707500005140903, + 0.00047664999874541536, + 0.00046954159915912896, + 0.0004611915996065363, + 0.00046158339973771944, + 0.00046169160050339996, + 0.0004665165994083509, + 0.00046135000011418017 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/T3/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/T3/talib]", + "params": { + "indicator": "T3", + "library": "talib" + }, + "param": "Overlap/T3/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003904750003130175, + "max": 0.0004103583996766247, + "mean": 0.00039705916984530634, + "stddev": 5.785907270875115e-06, + "rounds": 20, + "median": 0.0003955374995712191, + "iqr": 5.92919896007509e-06, + "q1": 0.000392820800334448, + "q3": 0.0003987499992945231, + "iqr_outliers": 2, + "stddev_outliers": 6, + "outliers": "6;2", + "ld15iqr": 0.0003904750003130175, + "hd15iqr": 0.0004079999998793937, + "ops": 2518.516321861043, + "total": 0.007941183396906127, + "data": [ + 0.0003938084002584219, + 0.00039481679996242745, + 0.00039409160090144726, + 0.00039678339962847533, + 0.00040539159963373097, + 0.0004079999998793937, + 0.0003924499993445352, + 0.0004103583996766247, + 0.00039899999974295496, + 0.0003919668000889942, + 0.0003984999988460913, + 0.00039296659961109983, + 0.00039554999966640025, + 0.0003910499988705851, + 0.0003962415998103097, + 0.0003955249994760379, + 0.00039605819911230354, + 0.0003926750010577962, + 0.0003904750003130175, + 0.00040547500102547927 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/T3/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/T3/pandas_ta]", + "params": { + "indicator": "T3", + "library": "pandas_ta" + }, + "param": "Overlap/T3/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004631916002836078, + "max": 0.0004957249999279157, + "mean": 0.0004715829002088867, + "stddev": 7.986782228054758e-06, + "rounds": 20, + "median": 0.00046880410009180195, + "iqr": 7.112499588401988e-06, + "q1": 0.00046694580087205397, + "q3": 0.00047405830046045596, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0004631916002836078, + "hd15iqr": 0.0004957249999279157, + "ops": 2120.5179398087844, + "total": 0.009431658004177734, + "data": [ + 0.0004957249999279157, + 0.00046735820069443434, + 0.0004682915998273529, + 0.0004681667996919714, + 0.00046653340104967355, + 0.00047220840060617774, + 0.00046932500117691234, + 0.0004751750006107613, + 0.0004676415992435068, + 0.00046435840049525724, + 0.00047542499960400163, + 0.00048456660006195306, + 0.00047294160031015054, + 0.00046741679980186743, + 0.00048243319906760007, + 0.0004711583998869173, + 0.0004652832009014674, + 0.000469316600356251, + 0.00046514160057995466, + 0.0004631916002836078 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/ferro_ta]", + "params": { + "indicator": "TRIMA", + "library": "ferro_ta" + }, + "param": "Overlap/TRIMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005690834004781209, + "max": 0.0005970833997707814, + "mean": 0.0005792178997944575, + "stddev": 7.400997792583969e-06, + "rounds": 20, + "median": 0.0005769540999608579, + "iqr": 1.025430028676064e-05, + "q1": 0.0005741415996453724, + "q3": 0.0005843958999321331, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005690834004781209, + "hd15iqr": 0.0005970833997707814, + "ops": 1726.4659817227027, + "total": 0.011584357995889149, + "data": [ + 0.0005916331996559165, + 0.0005785499990452081, + 0.0005717167994589544, + 0.0005742166002164594, + 0.0005773749988293275, + 0.0005724333997932263, + 0.0005740665990742854, + 0.0005737750005209818, + 0.0005750750002334826, + 0.0005759499996202067, + 0.0005765332010923885, + 0.0005890415995963849, + 0.0005818250006996095, + 0.0005970833997707814, + 0.0005774416000349447, + 0.0005869667991646565, + 0.0005873415997484699, + 0.00057785819954006, + 0.0005690834004781209, + 0.0005763915993156843 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/talib]", + "params": { + "indicator": "TRIMA", + "library": "talib" + }, + "param": "Overlap/TRIMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00036660840123659, + "max": 0.0006999082004767842, + "mean": 0.00042776169000717344, + "stddev": 7.45471836633297e-05, + "rounds": 20, + "median": 0.00040273749982588924, + "iqr": 5.077079986222084e-05, + "q1": 0.00038874169986229394, + "q3": 0.0004395124997245148, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.00036660840123659, + "hd15iqr": 0.0006999082004767842, + "ops": 2337.7502552489686, + "total": 0.00855523380014347, + "data": [ + 0.00040434179973090065, + 0.0006999082004767842, + 0.0004100417994777672, + 0.00037741659907624124, + 0.00036660840123659, + 0.0004030249998322688, + 0.0004135499999392778, + 0.00038741680036764593, + 0.0003934668013243936, + 0.00040179999923566355, + 0.00040244999981950966, + 0.0004944582004100084, + 0.000453166599618271, + 0.0004686168002081104, + 0.00042585839983075855, + 0.0005048667997471056, + 0.00038970839959802107, + 0.0003781666004215367, + 0.00038777500012656676, + 0.00039259159966604785 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/pandas_ta]", + "params": { + "indicator": "TRIMA", + "library": "pandas_ta" + }, + "param": "Overlap/TRIMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004549165998469107, + "max": 0.00048071659984998404, + "mean": 0.00046667375005199574, + "stddev": 7.124904265168961e-06, + "rounds": 20, + "median": 0.0004655500000808388, + "iqr": 8.375100151170045e-06, + "q1": 0.0004632625001249835, + "q3": 0.00047163760027615356, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0004549165998469107, + "hd15iqr": 0.00048071659984998404, + "ops": 2142.8246176018733, + "total": 0.009333475001039915, + "data": [ + 0.00047868340043351055, + 0.00046737499942537395, + 0.00046685840061400086, + 0.00046342499990714714, + 0.0004732250003144145, + 0.0004730583998025395, + 0.00048071659984998404, + 0.00046830000064801425, + 0.0004583082001772709, + 0.00046310000034281983, + 0.00047021680074976757, + 0.0004656249991967343, + 0.0004644415996153839, + 0.0004549165998469107, + 0.0004654750009649433, + 0.0004553581995423883, + 0.0004586915994877927, + 0.00046445839980151503, + 0.0004652917996281758, + 0.0004759500006912276 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/tulipy]", + "params": { + "indicator": "TRIMA", + "library": "tulipy" + }, + "param": "Overlap/TRIMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003771749994484708, + "max": 0.00042373340111225846, + "mean": 0.00039389624987961724, + "stddev": 1.1549059302988846e-05, + "rounds": 20, + "median": 0.0003916209003364202, + "iqr": 1.656669919611884e-05, + "q1": 0.000384933299937984, + "q3": 0.00040149999913410286, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0003771749994484708, + "hd15iqr": 0.00042373340111225846, + "ops": 2538.7395800432737, + "total": 0.007877924997592345, + "data": [ + 0.00038641659921268, + 0.0003919668000889942, + 0.00038528320001205427, + 0.00039127500058384613, + 0.0003845250001177192, + 0.0003948333993321285, + 0.0003849665998131968, + 0.0004117583986953832, + 0.00039273339934879913, + 0.00042373340111225846, + 0.0003833081995253451, + 0.00040528340032324194, + 0.00040443320031045, + 0.0003771749994484708, + 0.00040279159875353796, + 0.0003827834007097408, + 0.0004002083995146677, + 0.00038490000006277116, + 0.00038943340041441843, + 0.00040011660021264104 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/finta]", + "params": { + "indicator": "TRIMA", + "library": "finta" + }, + "param": "Overlap/TRIMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0018327832003706135, + "max": 0.007367949999752455, + "mean": 0.0035420641499513293, + "stddev": 0.0011082252186320944, + "rounds": 20, + "median": 0.0033642248999967705, + "iqr": 0.0006917875005456155, + "q1": 0.0029140790997189466, + "q3": 0.003605866600264562, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.0028360915996017864, + "hd15iqr": 0.005188375001307577, + "ops": 282.32125609970694, + "total": 0.07084128299902659, + "data": [ + 0.007367949999752455, + 0.0028507000009994955, + 0.0032192668004427105, + 0.003614266599470284, + 0.003597466601058841, + 0.0032295333992806265, + 0.003498550000949763, + 0.004164749999472406, + 0.0033440166007494554, + 0.0029622916001244446, + 0.003783574998669792, + 0.002865866599313449, + 0.0028413833992090077, + 0.0033844331992440857, + 0.0028360915996017864, + 0.0034904999993159436, + 0.0035085499999695457, + 0.0018327832003706135, + 0.0032609333997243085, + 0.005188375001307577 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/KAMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/KAMA/ferro_ta]", + "params": { + "indicator": "KAMA", + "library": "ferro_ta" + }, + "param": "Overlap/KAMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010542999996687285, + "max": 0.005002533399965614, + "mean": 0.0019874978897860274, + "stddev": 0.0009545136153215923, + "rounds": 20, + "median": 0.0019252332000178284, + "iqr": 0.0009617250994779164, + "q1": 0.0012667208000493703, + "q3": 0.0022284458995272868, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0010542999996687285, + "hd15iqr": 0.0037077000000863337, + "ops": 503.14518829887123, + "total": 0.03974995779572055, + "data": [ + 0.0011940750002395362, + 0.0019299831998068838, + 0.0024959665999631396, + 0.002314674999797717, + 0.0014946583993150852, + 0.0010542999996687285, + 0.0015709499988588505, + 0.005002533399965614, + 0.001073308200284373, + 0.0037077000000863337, + 0.0022994499988271853, + 0.001920483200228773, + 0.0019610584000474772, + 0.002089574999990873, + 0.0013393665998592042, + 0.0020525915999314746, + 0.0018844665988581254, + 0.0021574418002273887, + 0.0011249415998463518, + 0.0010824331999174318 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/KAMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/KAMA/talib]", + "params": { + "indicator": "KAMA", + "library": "talib" + }, + "param": "Overlap/KAMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003870084008667618, + "max": 0.0008882417998393067, + "mean": 0.00046930751006584614, + "stddev": 0.0001464840371167732, + "rounds": 20, + "median": 0.000412904100085143, + "iqr": 4.1591699846321684e-05, + "q1": 0.0003990416000306141, + "q3": 0.0004406332998769358, + "iqr_outliers": 4, + "stddev_outliers": 2, + "outliers": "2;4", + "ld15iqr": 0.0003870084008667618, + "hd15iqr": 0.0005217750003794208, + "ops": 2130.799057231569, + "total": 0.009386150201316923, + "data": [ + 0.00040840000001480804, + 0.00039603339973837137, + 0.0004403500002808869, + 0.00043270000023767354, + 0.00039976679981919007, + 0.00039300000062212346, + 0.00039929159975145013, + 0.0005217750003794208, + 0.0008882417998393067, + 0.0005391750004491769, + 0.0008748334003030322, + 0.00044091659947298467, + 0.00041772499971557406, + 0.00040653340111020955, + 0.000398791600309778, + 0.0004124915998545475, + 0.0004185333993518725, + 0.0003870084008667618, + 0.0003972665988840163, + 0.00041331660031573847 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/KAMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/KAMA/pandas_ta]", + "params": { + "indicator": "KAMA", + "library": "pandas_ta" + }, + "param": "Overlap/KAMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.1391765584005043, + "max": 0.21343391659902408, + "mean": 0.15199162670003716, + "stddev": 0.02320810803367161, + "rounds": 20, + "median": 0.14112020839966133, + "iqr": 0.008807879200321611, + "q1": 0.1399587291998614, + "q3": 0.14876660840018302, + "iqr_outliers": 4, + "stddev_outliers": 3, + "outliers": "3;4", + "ld15iqr": 0.1391765584005043, + "hd15iqr": 0.1650585500014131, + "ops": 6.57930980614839, + "total": 3.0398325340007433, + "data": [ + 0.1399850249988958, + 0.1393613499996718, + 0.14321359180030413, + 0.14030661680008052, + 0.1396770999999717, + 0.14021284180053045, + 0.1391765584005043, + 0.14111357499932636, + 0.14112684179999632, + 0.15431962500006194, + 0.205352225000388, + 0.141088158400089, + 0.1417335499994806, + 0.14171715840057003, + 0.14241696660028538, + 0.21343391659902408, + 0.13975329179957044, + 0.1908531581997522, + 0.1650585500014131, + 0.139932433400827 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/KAMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/KAMA/tulipy]", + "params": { + "indicator": "KAMA", + "library": "tulipy" + }, + "param": "Overlap/KAMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000364641600754112, + "max": 0.00047417500027222557, + "mean": 0.00039751708005496766, + "stddev": 3.246935402453864e-05, + "rounds": 20, + "median": 0.0003838917000393849, + "iqr": 3.7316600355552455e-05, + "q1": 0.00037303749995771797, + "q3": 0.0004103541003132704, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.000364641600754112, + "hd15iqr": 0.00047417500027222557, + "ops": 2515.615177747136, + "total": 0.007950341601099354, + "data": [ + 0.0004436834002262913, + 0.00037986660026945174, + 0.00047417500027222557, + 0.0004120081997825764, + 0.00045134999963920565, + 0.0003846665989840403, + 0.000364641600754112, + 0.0004467833990929648, + 0.00040404180035693573, + 0.00040071660041576254, + 0.0003735749996849336, + 0.0003699084001709707, + 0.0003806916007306427, + 0.0004087000008439645, + 0.00038147499872138725, + 0.0003680666006403044, + 0.00036570840020431203, + 0.00037250000023050236, + 0.0003843833997962065, + 0.00038340000028256325 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/HULL_MA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/HULL_MA/ferro_ta]", + "params": { + "indicator": "HULL_MA", + "library": "ferro_ta" + }, + "param": "Overlap/HULL_MA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005305333994328976, + "max": 0.0006424000006518326, + "mean": 0.0005622504198981914, + "stddev": 2.790051700449531e-05, + "rounds": 20, + "median": 0.0005513166994205676, + "iqr": 2.6862500089919238e-05, + "q1": 0.0005448832998808939, + "q3": 0.0005717457999708131, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0005305333994328976, + "hd15iqr": 0.0006424000006518326, + "ops": 1778.566924291623, + "total": 0.01124500839796383, + "data": [ + 0.0005305333994328976, + 0.0005535333999432624, + 0.0006424000006518326, + 0.0005426750009064563, + 0.0005490999988978729, + 0.0005476499994983896, + 0.0005440499997348524, + 0.0006093083997257054, + 0.0005672833998687565, + 0.0005462500004796312, + 0.0005762082000728697, + 0.0005562250007642433, + 0.0005415084000560455, + 0.0005457166000269354, + 0.000533658399945125, + 0.0005625250007142313, + 0.0005868915992323309, + 0.0005471665994264185, + 0.0005672749990480952, + 0.0005950499995378778 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/HULL_MA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/HULL_MA/pandas_ta]", + "params": { + "indicator": "HULL_MA", + "library": "pandas_ta" + }, + "param": "Overlap/HULL_MA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0009729582001455128, + "max": 0.001094641600502655, + "mean": 0.0010229258199979086, + "stddev": 3.451736167777335e-05, + "rounds": 20, + "median": 0.0010193750997132154, + "iqr": 4.168739906162955e-05, + "q1": 0.0010015417006798088, + "q3": 0.0010432290997414383, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0009729582001455128, + "hd15iqr": 0.001094641600502655, + "ops": 977.5879936260135, + "total": 0.02045851639995817, + "data": [ + 0.0010675249999621884, + 0.0009940168005414308, + 0.0010250167993945069, + 0.0010084915993502364, + 0.0009730749996379017, + 0.0010097916005179287, + 0.001094641600502655, + 0.0010664999994332903, + 0.0009729582001455128, + 0.001001758400525432, + 0.0010759331998997368, + 0.0010318333996110595, + 0.0010297249988070688, + 0.0010267834004480392, + 0.0010013250008341855, + 0.0010137334000319242, + 0.001044749999709893, + 0.0010033915998064913, + 0.0010417081997729839, + 0.0009755582010257058 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/HULL_MA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/HULL_MA/tulipy]", + "params": { + "indicator": "HULL_MA", + "library": "tulipy" + }, + "param": "Overlap/HULL_MA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00036898340040352194, + "max": 0.00041155840008286757, + "mean": 0.0003814953800610965, + "stddev": 1.1024062672493326e-05, + "rounds": 20, + "median": 0.00037945410076645203, + "iqr": 1.5237500338116672e-05, + "q1": 0.00037228329965728336, + "q3": 0.00038752079999540003, + "iqr_outliers": 1, + "stddev_outliers": 7, + "outliers": "7;1", + "ld15iqr": 0.00036898340040352194, + "hd15iqr": 0.00041155840008286757, + "ops": 2621.2637223545144, + "total": 0.00762990760122193, + "data": [ + 0.00038786660006735475, + 0.0003796332006459124, + 0.00041155840008286757, + 0.0003865915990900248, + 0.00039283320074900985, + 0.00037537500029429796, + 0.0003958916000556201, + 0.000392866600304842, + 0.00038717499992344526, + 0.00037428320065373554, + 0.00037613319873344155, + 0.0003845084007480182, + 0.0003815332005615346, + 0.0003713418002007529, + 0.0003720165987033397, + 0.00037255000061122703, + 0.00036898340040352194, + 0.0003698831991641782, + 0.00036960839934181423, + 0.00037927500088699164 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/HULL_MA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/HULL_MA/finta]", + "params": { + "indicator": "HULL_MA", + "library": "finta" + }, + "param": "Overlap/HULL_MA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.3176775915999315, + "max": 0.40398270000005143, + "mean": 0.3312261416698311, + "stddev": 0.023181257965196444, + "rounds": 20, + "median": 0.3240056583999831, + "iqr": 0.008467429099982826, + "q1": 0.3202927166996233, + "q3": 0.3287601457996061, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.3176775915999315, + "hd15iqr": 0.3913448834005976, + "ops": 3.019085374598265, + "total": 6.624522833396623, + "data": [ + 0.40398270000005143, + 0.3213010249994113, + 0.3186954999997397, + 0.3189592665992677, + 0.32296742500038816, + 0.3913448834005976, + 0.3182757082002354, + 0.31928440839983524, + 0.32265284999884897, + 0.3176775915999315, + 0.3261065418002545, + 0.32268198340025267, + 0.32299688339990096, + 0.32848442499962405, + 0.331737200000498, + 0.3250144334000652, + 0.32986117499967804, + 0.3263368249987252, + 0.32903586659958817, + 0.32712614159972875 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/VWMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/VWMA/ferro_ta]", + "params": { + "indicator": "VWMA", + "library": "ferro_ta" + }, + "param": "Overlap/VWMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00032898339995881545, + "max": 0.0003606749989558011, + "mean": 0.00033885874996485653, + "stddev": 9.885964372872836e-06, + "rounds": 20, + "median": 0.00033574580011190845, + "iqr": 1.1375000030966432e-05, + "q1": 0.00033083749949582855, + "q3": 0.000342212499526795, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.00032898339995881545, + "hd15iqr": 0.0003606749989558011, + "ops": 2951.0821252327446, + "total": 0.006777174999297131, + "data": [ + 0.00033686660026432946, + 0.0003333582004415803, + 0.00033382500114385036, + 0.0003387668009963818, + 0.0003305831996840425, + 0.00033462499995948745, + 0.00034187499986728655, + 0.0003514331998303533, + 0.0003606749989558011, + 0.00035643339942907913, + 0.0003553168004145846, + 0.00034254999918630346, + 0.00033207499946001916, + 0.0003399250010261312, + 0.00033109179930761455, + 0.0003293082001619041, + 0.00032940840028459204, + 0.00032898339995881545, + 0.00032957499934127554, + 0.0003404999995836988 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/VWMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/VWMA/pandas_ta]", + "params": { + "indicator": "VWMA", + "library": "pandas_ta" + }, + "param": "Overlap/VWMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006855583997094072, + "max": 0.0008447000000160187, + "mean": 0.0007344595998438308, + "stddev": 3.551653002419854e-05, + "rounds": 20, + "median": 0.0007309625994821545, + "iqr": 3.340010007377712e-05, + "q1": 0.000713937499676831, + "q3": 0.0007473375997506082, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0006855583997094072, + "hd15iqr": 0.0008447000000160187, + "ops": 1361.5452779330972, + "total": 0.014689191996876617, + "data": [ + 0.0007830332004232332, + 0.0006974250005441718, + 0.0006941250001545995, + 0.0006855583997094072, + 0.0007137499997043051, + 0.0008447000000160187, + 0.0007303083999431692, + 0.0007169584001530893, + 0.0007399665992124937, + 0.0007186834001913667, + 0.0007610999993630685, + 0.0007083000004058704, + 0.0007384915996226482, + 0.000723041599849239, + 0.0007316167990211398, + 0.0007331083994358778, + 0.0007412667997414246, + 0.0007602249999763444, + 0.000714124999649357, + 0.0007534083997597918 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/VWMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/VWMA/tulipy]", + "params": { + "indicator": "VWMA", + "library": "tulipy" + }, + "param": "Overlap/VWMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004032418000861071, + "max": 0.00046014999970793723, + "mean": 0.000420225410052808, + "stddev": 1.4125305192513432e-05, + "rounds": 20, + "median": 0.00041793339987634677, + "iqr": 1.1191499652341008e-05, + "q1": 0.0004100251004274469, + "q3": 0.0004212166000797879, + "iqr_outliers": 3, + "stddev_outliers": 4, + "outliers": "4;3", + "ld15iqr": 0.0004032418000861071, + "hd15iqr": 0.00043978319881716744, + "ops": 2379.67523161994, + "total": 0.00840450820105616, + "data": [ + 0.0004196499998215586, + 0.00041659180133137854, + 0.0004202915995847434, + 0.0004182083997875452, + 0.0004467666003620252, + 0.00041530820017214863, + 0.00043978319881716744, + 0.0004176583999651484, + 0.0004032418000861071, + 0.0004078833997482434, + 0.00040820819995133204, + 0.0004100418009329587, + 0.0004221416005748324, + 0.00041000839992193504, + 0.00041250840004067866, + 0.0004080665996298194, + 0.00046014999970793723, + 0.0004202750002150424, + 0.00041993320046458394, + 0.00042779159994097424 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/MIDPOINT/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/MIDPOINT/ferro_ta]", + "params": { + "indicator": "MIDPOINT", + "library": "ferro_ta" + }, + "param": "Overlap/MIDPOINT/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0013064832004602068, + "max": 0.0015063250000821426, + "mean": 0.0013730866499827245, + "stddev": 5.3799846147194565e-05, + "rounds": 20, + "median": 0.001358662499842467, + "iqr": 7.263340012286811e-05, + "q1": 0.0013309540998307056, + "q3": 0.0014035874999535737, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0013064832004602068, + "hd15iqr": 0.0015063250000821426, + "ops": 728.286157332155, + "total": 0.027461732999654487, + "data": [ + 0.0014368584001204, + 0.0013654499998665415, + 0.0015063250000821426, + 0.0014643165995948948, + 0.001402758399490267, + 0.0013771832003840246, + 0.0014044166004168802, + 0.0014398084007552826, + 0.0013608499997644686, + 0.0013216915991506538, + 0.0013732749997870997, + 0.0013563583997893147, + 0.0013348749998840503, + 0.0013244583999039606, + 0.001322591600182932, + 0.001356474999920465, + 0.001332025000010617, + 0.0013456500004394912, + 0.0013298831996507942, + 0.0013064832004602068 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/MIDPOINT/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/MIDPOINT/talib]", + "params": { + "indicator": "MIDPOINT", + "library": "talib" + }, + "param": "Overlap/MIDPOINT/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.004596699999819975, + "max": 0.004724758201336954, + "mean": 0.004642987910265219, + "stddev": 3.5744809807459475e-05, + "rounds": 20, + "median": 0.004627108400018187, + "iqr": 5.424589980975673e-05, + "q1": 0.004616320800414542, + "q3": 0.004670566700224299, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.004596699999819975, + "hd15iqr": 0.004724758201336954, + "ops": 215.3785491857715, + "total": 0.09285975820530439, + "data": [ + 0.004702741600340232, + 0.004669458400167059, + 0.004616591600643006, + 0.0046198082010960205, + 0.004660583200166002, + 0.004681733399047516, + 0.004679033400316257, + 0.004610891600896139, + 0.004724758201336954, + 0.004671675000281539, + 0.004596699999819975, + 0.00462952500092797, + 0.004657749999023508, + 0.004614341800333932, + 0.004622191601083614, + 0.004623975000868086, + 0.004637574999651406, + 0.004616050000186079, + 0.00459968340001069, + 0.004624691799108405 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/MIDPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/MIDPRICE/ferro_ta]", + "params": { + "indicator": "MIDPRICE", + "library": "ferro_ta" + }, + "param": "Overlap/MIDPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0011972168009378946, + "max": 0.0014547750004567205, + "mean": 0.0012470024900540012, + "stddev": 6.894870172849137e-05, + "rounds": 20, + "median": 0.001210433300002478, + "iqr": 9.76458999502937e-05, + "q1": 0.0012014457999612205, + "q3": 0.0012990916999115142, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0011972168009378946, + "hd15iqr": 0.0014547750004567205, + "ops": 801.9230177773705, + "total": 0.02494004980108002, + "data": [ + 0.0013083333993563428, + 0.0013131665997207164, + 0.0013024083993514069, + 0.001232624999829568, + 0.0012291915991227143, + 0.0012050834004185163, + 0.0012017000000923872, + 0.00120090840064222, + 0.0012140416001784615, + 0.0012068249998264946, + 0.0012011915998300538, + 0.0011977750007645227, + 0.0012050165998516605, + 0.0012055081999278628, + 0.0012168750006821937, + 0.001200466600130312, + 0.0011972168009378946, + 0.0013511665994883515, + 0.0014547750004567205, + 0.0012957750004716218 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/MIDPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/MIDPRICE/talib]", + "params": { + "indicator": "MIDPRICE", + "library": "talib" + }, + "param": "Overlap/MIDPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008010165998712182, + "max": 0.0008403250001720152, + "mean": 0.0008119945802172879, + "stddev": 1.1666465569998526e-05, + "rounds": 20, + "median": 0.0008089667004242073, + "iqr": 1.2987499212613308e-05, + "q1": 0.0008027958007005509, + "q3": 0.0008157832999131642, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0008010165998712182, + "hd15iqr": 0.0008369168004719541, + "ops": 1231.5353136130568, + "total": 0.016239891604345757, + "data": [ + 0.0008403250001720152, + 0.0008025750008528121, + 0.0008168665997800417, + 0.0008095750003121793, + 0.0008042834000661969, + 0.0008052584002143703, + 0.0008097915997495875, + 0.0008083584005362354, + 0.0008010165998712182, + 0.0008073416000115685, + 0.0008369168004719541, + 0.000830483200843446, + 0.0008147000000462868, + 0.0008023665999644436, + 0.0008022666006581858, + 0.0008030166005482897, + 0.0008015168001293205, + 0.0008134584000799805, + 0.0008108166002784856, + 0.0008189583997591399 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/ferro_ta]", + "params": { + "indicator": "RSI", + "library": "ferro_ta" + }, + "param": "Momentum/RSI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006271584003116004, + "max": 0.0006934584002010524, + "mean": 0.0006363537698780419, + "stddev": 1.4732478855902815e-05, + "rounds": 20, + "median": 0.0006313666999631096, + "iqr": 6.233199383131986e-06, + "q1": 0.0006297001003986225, + "q3": 0.0006359332997817545, + "iqr_outliers": 3, + "stddev_outliers": 1, + "outliers": "1;3", + "ld15iqr": 0.0006271584003116004, + "hd15iqr": 0.0006459000011091121, + "ops": 1571.452935985044, + "total": 0.01272707539756084, + "data": [ + 0.0006459000011091121, + 0.0006290249992161989, + 0.0006314583995845168, + 0.0006371666007908061, + 0.0006934584002010524, + 0.0006346999987727031, + 0.0006322332003037446, + 0.0006301749992417172, + 0.0006310750002739951, + 0.0006305417991825379, + 0.0006293668004218489, + 0.0006312750003417023, + 0.0006271584003116004, + 0.0006397667995770462, + 0.0006510166000225581, + 0.000630033400375396, + 0.000628366599266883, + 0.0006334749996312894, + 0.0006286833988269791, + 0.000632200000109151 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/talib]", + "params": { + "indicator": "RSI", + "library": "talib" + }, + "param": "Momentum/RSI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006214666005689651, + "max": 0.000653283199062571, + "mean": 0.0006308941401221091, + "stddev": 1.0335447950868837e-05, + "rounds": 20, + "median": 0.0006256709006265738, + "iqr": 1.6258299729088235e-05, + "q1": 0.0006228707999980543, + "q3": 0.0006391290997271426, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0006214666005689651, + "hd15iqr": 0.000653283199062571, + "ops": 1585.0519705357394, + "total": 0.012617882802442183, + "data": [ + 0.0006296666004345752, + 0.000646516599226743, + 0.000639558199327439, + 0.0006440415992983617, + 0.000625791800848674, + 0.0006233831998542882, + 0.000629158400988672, + 0.0006214666005689651, + 0.0006221832009032369, + 0.0006255500004044734, + 0.0006253833998925984, + 0.0006493082008091732, + 0.000653283199062571, + 0.0006387000001268461, + 0.0006294334001722745, + 0.0006228332000318915, + 0.0006221000003279187, + 0.0006222167998203076, + 0.0006244000003789551, + 0.000622908399964217 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/pandas_ta]", + "params": { + "indicator": "RSI", + "library": "pandas_ta" + }, + "param": "Momentum/RSI/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006943999993382022, + "max": 0.0007160581997595727, + "mean": 0.0007010841596638784, + "stddev": 7.423360643277134e-06, + "rounds": 20, + "median": 0.0006981416998314671, + "iqr": 7.208299211924961e-06, + "q1": 0.0006956917000934481, + "q3": 0.000702899999305373, + "iqr_outliers": 3, + "stddev_outliers": 4, + "outliers": "4;3", + "ld15iqr": 0.0006943999993382022, + "hd15iqr": 0.0007153667989769019, + "ops": 1426.3622793580605, + "total": 0.014021683193277568, + "data": [ + 0.0007153667989769019, + 0.0007007749998592771, + 0.0006979083991609514, + 0.000696125000831671, + 0.0006962415995076298, + 0.0006947582005523145, + 0.000704274998861365, + 0.0007157500003813766, + 0.0006996249998337589, + 0.0006951833987841382, + 0.0006952583993552252, + 0.0006979415993555449, + 0.0006943999993382022, + 0.0006985415995586664, + 0.0006946166002308018, + 0.0007015249997493811, + 0.0007160581997595727, + 0.0007112249993951991, + 0.0006983418003073894, + 0.0006977665994782001 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/ta]", + "params": { + "indicator": "RSI", + "library": "ta" + }, + "param": "Momentum/RSI/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0016815499999211169, + "max": 0.001762908199452795, + "mean": 0.0017002337299345527, + "stddev": 2.1557330871896647e-05, + "rounds": 20, + "median": 0.0016931416997977068, + "iqr": 1.9291698845336257e-05, + "q1": 0.0016850875006639398, + "q3": 0.001704379199509276, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0016815499999211169, + "hd15iqr": 0.0017354331997921691, + "ops": 588.154429825653, + "total": 0.034004674598691054, + "data": [ + 0.001694800000404939, + 0.0016845000005559995, + 0.0016850166008225641, + 0.0017326081986539065, + 0.0016873665997991338, + 0.0016815499999211169, + 0.0017043749990989453, + 0.001762908199452795, + 0.0016851584005053155, + 0.001686050000716932, + 0.0017142166005214676, + 0.0017037249999702908, + 0.0016914833991904742, + 0.0016867583995917811, + 0.0017043833999196068, + 0.0016815666007460096, + 0.0016826084000058472, + 0.0017013749995385297, + 0.0017354331997921691, + 0.0016987915994832292 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/tulipy]", + "params": { + "indicator": "RSI", + "library": "tulipy" + }, + "param": "Momentum/RSI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00035900840011890975, + "max": 0.00038547500007553027, + "mean": 0.0003663266600051429, + "stddev": 8.626350634918524e-06, + "rounds": 20, + "median": 0.0003619041002821177, + "iqr": 1.2058400898240507e-05, + "q1": 0.00036042919964529574, + "q3": 0.00037248760054353625, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.00035900840011890975, + "hd15iqr": 0.00038547500007553027, + "ops": 2729.8040497133375, + "total": 0.007326533200102858, + "data": [ + 0.00037502499908441677, + 0.00037025839992566034, + 0.0003854249996948056, + 0.00038547500007553027, + 0.0003630250008427538, + 0.00036109999928157777, + 0.0003608165992773138, + 0.00036094160022912545, + 0.00036175819986965506, + 0.0003593583998735994, + 0.0003632166000897996, + 0.00036188320082146673, + 0.0003600418000132777, + 0.00035998319945065307, + 0.00036535840044962244, + 0.00037471680116141215, + 0.0003619249997427687, + 0.0003596499998820946, + 0.00035900840011890975, + 0.00037756660021841525 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/finta]", + "params": { + "indicator": "RSI", + "library": "finta" + }, + "param": "Momentum/RSI/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0022185581998201086, + "max": 0.00235590820084326, + "mean": 0.0022495583201089177, + "stddev": 3.3812011696868017e-05, + "rounds": 20, + "median": 0.0022424374998081475, + "iqr": 3.134580038022293e-05, + "q1": 0.0022244542000407815, + "q3": 0.0022558000004210045, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0022185581998201086, + "hd15iqr": 0.00235590820084326, + "ops": 444.531706984855, + "total": 0.04499116640217835, + "data": [ + 0.00235590820084326, + 0.002278491600009147, + 0.002241941599640995, + 0.002283283400174696, + 0.0022353084001224487, + 0.002234791799855884, + 0.0022429333999752997, + 0.0022185581998201086, + 0.0022436166007537396, + 0.002252524999494199, + 0.002253175000078045, + 0.002247158200771082, + 0.0022265334002440794, + 0.002222374999837484, + 0.002258425000763964, + 0.0022192916003405116, + 0.00222231660009129, + 0.0023019833999569526, + 0.0022211999996216035, + 0.0022313499997835607 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/ferro_ta]", + "params": { + "indicator": "MACD", + "library": "ferro_ta" + }, + "param": "Momentum/MACD/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008245917997555807, + "max": 0.0008499165996909142, + "mean": 0.0008328883400099585, + "stddev": 8.045669464210374e-06, + "rounds": 20, + "median": 0.0008296625004732051, + "iqr": 1.0253999789711088e-05, + "q1": 0.0008272043000033591, + "q3": 0.0008374582997930702, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0008245917997555807, + "hd15iqr": 0.0008499165996909142, + "ops": 1200.6411327454093, + "total": 0.01665776680019917, + "data": [ + 0.0008309666009154171, + 0.0008296084008179605, + 0.0008297166001284495, + 0.0008269918005680665, + 0.0008499165996909142, + 0.000833625000086613, + 0.000828466599341482, + 0.0008283083996502682, + 0.0008324084003106691, + 0.0008262082003057003, + 0.0008274167994386517, + 0.000842525000916794, + 0.0008412915994995274, + 0.0008461750010610558, + 0.0008312334000947885, + 0.0008258665999164805, + 0.0008258249989012256, + 0.0008245917997555807, + 0.0008284333991468884, + 0.0008481915996526368 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/talib]", + "params": { + "indicator": "MACD", + "library": "talib" + }, + "param": "Momentum/MACD/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007829750000382773, + "max": 0.0008204582001781092, + "mean": 0.0007931349899445195, + "stddev": 1.1300551916390505e-05, + "rounds": 20, + "median": 0.0007887457999459003, + "iqr": 1.4149899652693356e-05, + "q1": 0.0007852333998016548, + "q3": 0.0007993832994543481, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0007829750000382773, + "hd15iqr": 0.0008204582001781092, + "ops": 1260.819422517157, + "total": 0.01586269979889039, + "data": [ + 0.0007856331998482346, + 0.0007851500005926937, + 0.0007853167990106158, + 0.0007884165999712423, + 0.0007921833996078931, + 0.0007998415996553377, + 0.0008068084003753029, + 0.0007899167991126888, + 0.0007829750000382773, + 0.0007839500001864507, + 0.0007838332006940618, + 0.0007858166005462408, + 0.0007989249992533587, + 0.0008195583999622613, + 0.0008002999995369465, + 0.0007926416001282632, + 0.0007890749999205582, + 0.0007870583998737857, + 0.0007848416003980674, + 0.0008204582001781092 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/pandas_ta]", + "params": { + "indicator": "MACD", + "library": "pandas_ta" + }, + "param": "Momentum/MACD/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010066333998111077, + "max": 0.0010455583993461913, + "mean": 0.0010202854198723798, + "stddev": 1.0313282247086423e-05, + "rounds": 20, + "median": 0.0010198374999163206, + "iqr": 1.6149900329764976e-05, + "q1": 0.0010111500996572431, + "q3": 0.001027299999987008, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0010066333998111077, + "hd15iqr": 0.0010455583993461913, + "ops": 980.117896936215, + "total": 0.020405708397447597, + "data": [ + 0.0010342166002374142, + 0.0010223831995972432, + 0.001021141599630937, + 0.0010298250010237098, + 0.0010266665995004587, + 0.001014358400425408, + 0.0010104250002768822, + 0.001014091599790845, + 0.0010118833990418353, + 0.0010208166000666096, + 0.0010276665998389944, + 0.0010321167996153236, + 0.0010066333998111077, + 0.0010269334001350217, + 0.001011216799088288, + 0.0010188583997660316, + 0.0010455583993461913, + 0.0010104831992066466, + 0.001009350000822451, + 0.0010110834002261982 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/ta]", + "params": { + "indicator": "MACD", + "library": "ta" + }, + "param": "Momentum/MACD/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0015758918001665735, + "max": 0.0016479750003782101, + "mean": 0.0015926996202324517, + "stddev": 1.6698245983907632e-05, + "rounds": 20, + "median": 0.0015861792002397125, + "iqr": 1.664159935899079e-05, + "q1": 0.0015822501009097323, + "q3": 0.001598891700268723, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0015758918001665735, + "hd15iqr": 0.0016479750003782101, + "ops": 627.8647820949765, + "total": 0.031853992404649034, + "data": [ + 0.0015864583998336456, + 0.0015824334011995233, + 0.0015913665993139148, + 0.0016479750003782101, + 0.0015859000006457791, + 0.0015838917999644764, + 0.0015981165997800417, + 0.0016069165998487734, + 0.0015832000004593282, + 0.0015820668006199412, + 0.0015955334005411715, + 0.0016115999998874031, + 0.0015854334007599391, + 0.0015802168010850437, + 0.0015986416008672677, + 0.0015991417996701785, + 0.0015762334005557932, + 0.0015758918001665735, + 0.0016043417999753729, + 0.0015786331990966574 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/tulipy]", + "params": { + "indicator": "MACD", + "library": "tulipy" + }, + "param": "Momentum/MACD/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004089499998372048, + "max": 0.0004240333990310319, + "mean": 0.00041373039995960424, + "stddev": 4.815477685350209e-06, + "rounds": 20, + "median": 0.00041197910031769424, + "iqr": 4.983299731975421e-06, + "q1": 0.0004105667001567781, + "q3": 0.00041554999988875353, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0004089499998372048, + "hd15iqr": 0.0004240333990310319, + "ops": 2417.032927958975, + "total": 0.008274607999192085, + "data": [ + 0.00041595819930080325, + 0.00041228340123780074, + 0.00041205820016330106, + 0.0004119000004720874, + 0.00041078340000240133, + 0.0004151418004767038, + 0.0004240333990310319, + 0.00042297499894630166, + 0.0004211749997921288, + 0.00042174999980488793, + 0.0004122166006709449, + 0.00041145839932141823, + 0.0004103500003111549, + 0.0004101832004380412, + 0.00041103319963440297, + 0.00040967499953694644, + 0.00041218319965992124, + 0.0004096584001672454, + 0.0004108416003873572, + 0.0004089499998372048 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/finta]", + "params": { + "indicator": "MACD", + "library": "finta" + }, + "param": "Momentum/MACD/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0017074415998649783, + "max": 0.0017570666008396075, + "mean": 0.0017213978799554752, + "stddev": 1.4855512166017051e-05, + "rounds": 20, + "median": 0.0017131166001490782, + "iqr": 1.9791500380961398e-05, + "q1": 0.0017111333996581378, + "q3": 0.0017309249000390992, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0017074415998649783, + "hd15iqr": 0.0017570666008396075, + "ops": 580.9232203921765, + "total": 0.03442795759910951, + "data": [ + 0.0017111667999415658, + 0.0017366250001941807, + 0.0017131750006228685, + 0.00171109999937471, + 0.0017120999997132457, + 0.001748300000326708, + 0.001715608399535995, + 0.0017093250004108994, + 0.0017296416001045146, + 0.0017232332000276073, + 0.001713058199675288, + 0.0017106749990489333, + 0.0017322081999736837, + 0.0017123331999755464, + 0.0017124165999121033, + 0.0017176084002130665, + 0.0017448081998736598, + 0.0017100665994803422, + 0.0017074415998649783, + 0.0017570666008396075 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/ferro_ta]", + "params": { + "indicator": "STOCH", + "library": "ferro_ta" + }, + "param": "Momentum/STOCH/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002266316600434948, + "max": 0.0023576166000566444, + "mean": 0.002291999560184195, + "stddev": 2.225686339044173e-05, + "rounds": 20, + "median": 0.0022855458002595695, + "iqr": 2.903329877881368e-05, + "q1": 0.0022757875005481763, + "q3": 0.00230482079932699, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.002266316600434948, + "hd15iqr": 0.0023576166000566444, + "ops": 436.3002582424736, + "total": 0.04583999120368389, + "data": [ + 0.0023576166000566444, + 0.002298591600265354, + 0.0023118166005588136, + 0.002308683199225925, + 0.0022823250008514153, + 0.0022958166009630077, + 0.0022764416004065423, + 0.0022751334006898107, + 0.0023123083999962548, + 0.002277250000042841, + 0.0022799668004154228, + 0.0023205250006867574, + 0.0022737415987649, + 0.0022920915987924674, + 0.002287225000327453, + 0.002266316600434948, + 0.002283866600191686, + 0.00226762500096811, + 0.002271691600617487, + 0.0023009583994280545 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/talib]", + "params": { + "indicator": "STOCH", + "library": "talib" + }, + "param": "Momentum/STOCH/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008924000008846633, + "max": 0.0009281749997171573, + "mean": 0.0009014741798455361, + "stddev": 9.781014492191172e-06, + "rounds": 20, + "median": 0.0008975500000815373, + "iqr": 1.4066699804970995e-05, + "q1": 0.0008938375001889653, + "q3": 0.0009079041999939363, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0008924000008846633, + "hd15iqr": 0.0009281749997171573, + "ops": 1109.2941121966976, + "total": 0.01802948359691072, + "data": [ + 0.0009090084000490606, + 0.0009126083998125978, + 0.0009051415996509604, + 0.0008940833999076858, + 0.0008944415996666067, + 0.0008967916000983678, + 0.0008937665988923982, + 0.0008944250002969056, + 0.0009067999999388121, + 0.0009118499991018325, + 0.000901924999197945, + 0.0008937999999034218, + 0.0008936333993915469, + 0.0008924000008846633, + 0.0008983084000647068, + 0.0009156665997579694, + 0.0009281749997171573, + 0.0008991918002720922, + 0.0008938750004745088, + 0.0008935917998314835 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/pandas_ta]", + "params": { + "indicator": "STOCH", + "library": "pandas_ta" + }, + "param": "Momentum/STOCH/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012010416001430712, + "max": 0.001508349999494385, + "mean": 0.0012560820801445515, + "stddev": 9.674291831318799e-05, + "rounds": 20, + "median": 0.001210633300797781, + "iqr": 2.3974999203346756e-05, + "q1": 0.0012029666009766514, + "q3": 0.0012269416001799982, + "iqr_outliers": 4, + "stddev_outliers": 4, + "outliers": "4;4", + "ld15iqr": 0.0012010416001430712, + "hd15iqr": 0.0013852083997335286, + "ops": 796.1263167490763, + "total": 0.025121641602891032, + "data": [ + 0.0012280166003620252, + 0.0012069584001437761, + 0.0012017000000923872, + 0.0012010416001430712, + 0.001220774999819696, + 0.001205108399153687, + 0.0012029582008835859, + 0.0012011500002699904, + 0.001202975001069717, + 0.0012177499986137264, + 0.0012258665999979712, + 0.0012080666012479924, + 0.0012024750001728534, + 0.0012031834005028941, + 0.0012132000003475696, + 0.0012226165999891236, + 0.001508349999494385, + 0.0013852083997335286, + 0.0014482668004347943, + 0.0014159750004182569 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/ta]", + "params": { + "indicator": "STOCH", + "library": "ta" + }, + "param": "Momentum/STOCH/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0032632249989546836, + "max": 0.0038337831996614114, + "mean": 0.003435649139864836, + "stddev": 0.00015559524437584808, + "rounds": 20, + "median": 0.0034009916002105457, + "iqr": 0.00020378340050228837, + "q1": 0.0033035499996913135, + "q3": 0.003507333400193602, + "iqr_outliers": 1, + "stddev_outliers": 7, + "outliers": "7;1", + "ld15iqr": 0.0032632249989546836, + "hd15iqr": 0.0038337831996614114, + "ops": 291.06581006678164, + "total": 0.06871298279729672, + "data": [ + 0.003273491599247791, + 0.003310349999810569, + 0.003264858199690934, + 0.0032852668009581976, + 0.0032632249989546836, + 0.0032967499995720574, + 0.0034441581999999473, + 0.0038337831996614114, + 0.003594025000347756, + 0.003629208200436551, + 0.003525483400153462, + 0.0036791666003409772, + 0.003489183400233742, + 0.0033759500001906417, + 0.0034770165992085824, + 0.0034260332002304496, + 0.0034755499989842066, + 0.0033552665991010144, + 0.0033625334006501363, + 0.0033516833995236085 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/tulipy]", + "params": { + "indicator": "STOCH", + "library": "tulipy" + }, + "param": "Momentum/STOCH/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008966667999629862, + "max": 0.0010260584007482977, + "mean": 0.0009349141797429184, + "stddev": 3.282872273745214e-05, + "rounds": 20, + "median": 0.0009288583998568356, + "iqr": 4.016259917989369e-05, + "q1": 0.0009090624000236858, + "q3": 0.0009492249992035795, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0008966667999629862, + "hd15iqr": 0.0010260584007482977, + "ops": 1069.6168928307184, + "total": 0.018698283594858367, + "data": [ + 0.0009230668001691811, + 0.0009413250008947216, + 0.0009468749994994141, + 0.000937833399802912, + 0.0009532833995763212, + 0.0008966667999629862, + 0.0010260584007482977, + 0.0009515749989077449, + 0.0009331167995696888, + 0.0009246000001439825, + 0.000908558200171683, + 0.000913966799271293, + 0.0009061666001798585, + 0.0009160499990684912, + 0.0009374165994813666, + 0.0009011831993120722, + 0.0009095665998756885, + 0.0009051000000908971, + 0.00098769999895012, + 0.0009781749991816468 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/finta]", + "params": { + "indicator": "STOCH", + "library": "finta" + }, + "param": "Momentum/STOCH/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0033632334001595155, + "max": 0.0036877168007777073, + "mean": 0.003489002510032151, + "stddev": 0.00011377381099929499, + "rounds": 20, + "median": 0.0034526083996752276, + "iqr": 0.0002071709001029375, + "q1": 0.003392891599651193, + "q3": 0.0036000624997541307, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0033632334001595155, + "hd15iqr": 0.0036877168007777073, + "ops": 286.6148697585159, + "total": 0.06978005020064301, + "data": [ + 0.0035690333999809807, + 0.0035460500002955087, + 0.0036877168007777073, + 0.0036310915995272806, + 0.0036614166005165317, + 0.003523725000559352, + 0.0033972165998420677, + 0.0033632334001595155, + 0.0033794750008382833, + 0.0033666000002995134, + 0.003414750000229105, + 0.00342934179934673, + 0.0034004999994067474, + 0.0033905165997566654, + 0.003475875000003725, + 0.003366825000557583, + 0.003395266599545721, + 0.0036311833988293073, + 0.0036418917996343227, + 0.003508341600536369 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/ferro_ta]", + "params": { + "indicator": "CCI", + "library": "ferro_ta" + }, + "param": "Momentum/CCI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000883666799927596, + "max": 0.0010631166005623527, + "mean": 0.0009321975000784733, + "stddev": 5.2810083762242914e-05, + "rounds": 20, + "median": 0.0009106416997383349, + "iqr": 6.761670010746468e-05, + "q1": 0.0008935083002143073, + "q3": 0.000961125000321772, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.000883666799927596, + "hd15iqr": 0.0010631166005623527, + "ops": 1072.7340503657422, + "total": 0.018643950001569466, + "data": [ + 0.0009618081996450201, + 0.0009247750000213273, + 0.000884916799259372, + 0.0010631166005623527, + 0.0009058416006155312, + 0.0010151081994990818, + 0.0009604418009985238, + 0.0009248082002159208, + 0.000912600000447128, + 0.0009965749995899387, + 0.0008905668000807054, + 0.0009207499999320135, + 0.0008928833995014429, + 0.0008931666001444682, + 0.0008951666008215397, + 0.0008938500002841465, + 0.000883666799927596, + 0.0008953334006946533, + 0.0010198916002991608, + 0.0009086833990295418 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/talib]", + "params": { + "indicator": "CCI", + "library": "talib" + }, + "param": "Momentum/CCI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0009949418003088796, + "max": 0.0010386332010966725, + "mean": 0.0010135208402061834, + "stddev": 1.3763974688096278e-05, + "rounds": 20, + "median": 0.0010083916997245977, + "iqr": 2.270420009153895e-05, + "q1": 0.0010024250004789792, + "q3": 0.0010251292005705182, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0009949418003088796, + "hd15iqr": 0.0010386332010966725, + "ops": 986.6595341015061, + "total": 0.02027041680412367, + "data": [ + 0.001032058399869129, + 0.0010386332010966725, + 0.0010162081991438754, + 0.00100571660004789, + 0.0010327250012778677, + 0.0009999750007409602, + 0.0010113666008692234, + 0.0010007500008214266, + 0.0010059918000479228, + 0.0010001667993492446, + 0.0010058583997306415, + 0.0010262668001814745, + 0.0010107915994012728, + 0.0010018000000854954, + 0.0010041999994427897, + 0.0009949418003088796, + 0.0010239916009595618, + 0.001003050000872463, + 0.001037116600491572, + 0.001018808399385307 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/pandas_ta]", + "params": { + "indicator": "CCI", + "library": "pandas_ta" + }, + "param": "Momentum/CCI/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010963582011754625, + "max": 0.0013147999998182058, + "mean": 0.0011421566599165089, + "stddev": 5.5198956227109216e-05, + "rounds": 20, + "median": 0.0011159457004396244, + "iqr": 5.867500003660115e-05, + "q1": 0.001104158399539301, + "q3": 0.0011628333995759021, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0010963582011754625, + "hd15iqr": 0.0013147999998182058, + "ops": 875.5366361678435, + "total": 0.02284313319833018, + "data": [ + 0.0011700499992002733, + 0.0011390083993319422, + 0.001113766799971927, + 0.0011057083989726379, + 0.0011007584005710668, + 0.0011162331997184084, + 0.001114941798732616, + 0.0010985332002746873, + 0.0011007000008248723, + 0.0010963582011754625, + 0.0011026084001059643, + 0.001155616799951531, + 0.0012246500002220273, + 0.001194433199998457, + 0.0013147999998182058, + 0.0011482667992822825, + 0.001197083199804183, + 0.0011156582011608406, + 0.0011192499994649551, + 0.0011147081997478381 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/ta]", + "params": { + "indicator": "CCI", + "library": "ta" + }, + "param": "Momentum/CCI/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.34554610820050585, + "max": 0.4289616668000235, + "mean": 0.36346130584999625, + "stddev": 0.017391130092050962, + "rounds": 20, + "median": 0.35773772500033374, + "iqr": 0.011663533300452389, + "q1": 0.3554399959000875, + "q3": 0.3671035292005399, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.34554610820050585, + "hd15iqr": 0.4289616668000235, + "ops": 2.7513245121413528, + "total": 7.269226116999926, + "data": [ + 0.3539229416011949, + 0.35507729179953457, + 0.34554610820050585, + 0.34720233339903644, + 0.35598323339945637, + 0.36447610000032, + 0.3663169084000401, + 0.3626409333999618, + 0.37233330000017306, + 0.35812142500071786, + 0.37245304999960355, + 0.37471672499959824, + 0.36789015000103975, + 0.4289616668000235, + 0.3524450749988318, + 0.35725394159962887, + 0.3645183583998005, + 0.35580270000064046, + 0.3573540249999496, + 0.3562098499998683 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/tulipy]", + "params": { + "indicator": "CCI", + "library": "tulipy" + }, + "param": "Momentum/CCI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006111416005296633, + "max": 0.0006556418011314236, + "mean": 0.0006218199901923072, + "stddev": 1.13391937559075e-05, + "rounds": 20, + "median": 0.0006174042006023228, + "iqr": 1.3887600653106217e-05, + "q1": 0.0006141290999948979, + "q3": 0.0006280167006480041, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0006111416005296633, + "hd15iqr": 0.0006556418011314236, + "ops": 1608.1824575802636, + "total": 0.012436399803846143, + "data": [ + 0.0006292583988397382, + 0.0006188834013300948, + 0.0006144915998447687, + 0.000614533199404832, + 0.0006202666001627222, + 0.0006131250003818423, + 0.0006228168000234291, + 0.0006119415993453003, + 0.0006111416005296633, + 0.000628066599892918, + 0.0006556418011314236, + 0.0006415832001948729, + 0.0006313082005362958, + 0.0006137666001450271, + 0.0006230665996554308, + 0.0006151417997898534, + 0.0006159249998745509, + 0.0006151250010589138, + 0.000612350000301376, + 0.0006279668014030904 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/finta]", + "params": { + "indicator": "CCI", + "library": "finta" + }, + "param": "Momentum/CCI/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.2993529668005067, + "max": 0.3176451167993946, + "mean": 0.31254243002011206, + "stddev": 0.004638903107757841, + "rounds": 20, + "median": 0.3136391374995583, + "iqr": 0.005252683300204841, + "q1": 0.31079825839988184, + "q3": 0.3160509417000867, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.30593349160044453, + "hd15iqr": 0.3176451167993946, + "ops": 3.199565575578491, + "total": 6.250848600402241, + "data": [ + 0.2993529668005067, + 0.3176451167993946, + 0.31680982499965465, + 0.31562652499997057, + 0.3072172500003944, + 0.31052993339981183, + 0.3169236000001547, + 0.3128914000000805, + 0.31121485000039684, + 0.30593349160044453, + 0.3153166416013846, + 0.31505346660123906, + 0.31409360819961873, + 0.3110665833999519, + 0.3164753584002028, + 0.3138149666003301, + 0.3121581918006996, + 0.31346330839878644, + 0.30776771679957166, + 0.31749379999964733 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/ferro_ta]", + "params": { + "indicator": "WILLR", + "library": "ferro_ta" + }, + "param": "Momentum/WILLR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012404334003804252, + "max": 0.0014558499999111519, + "mean": 0.0013053404100355692, + "stddev": 5.708898775847718e-05, + "rounds": 20, + "median": 0.0012832583000999876, + "iqr": 5.272509952192195e-05, + "q1": 0.0012689583003520966, + "q3": 0.0013216833998740186, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.0012404334003804252, + "hd15iqr": 0.001435408399265725, + "ops": 766.0836915121251, + "total": 0.026106808200711384, + "data": [ + 0.0012898418004624545, + 0.0014558499999111519, + 0.0013338666001800447, + 0.0013162500006728805, + 0.0013211334007792175, + 0.001435408399265725, + 0.0013728331992751918, + 0.0012866916003986262, + 0.0012670249998336658, + 0.0012779081996995955, + 0.0012739415993564761, + 0.0012632500001927838, + 0.0012677000006078743, + 0.0012598333996720612, + 0.0012712416006252169, + 0.0012702166000963188, + 0.0013013250005315057, + 0.0012404334003804252, + 0.0013222333989688195, + 0.0012798249998013489 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/talib]", + "params": { + "indicator": "WILLR", + "library": "talib" + }, + "param": "Momentum/WILLR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007336750000831671, + "max": 0.0008699665995663963, + "mean": 0.0007845874800841557, + "stddev": 3.941710049435736e-05, + "rounds": 20, + "median": 0.0007887333005783149, + "iqr": 7.426669835695053e-05, + "q1": 0.0007435791005264037, + "q3": 0.0008178457988833542, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0007336750000831671, + "hd15iqr": 0.0008699665995663963, + "ops": 1274.555132963298, + "total": 0.015691749601683114, + "data": [ + 0.0007442332003847696, + 0.0007429250006680376, + 0.0007337750008446165, + 0.0007336750000831671, + 0.0008699665995663963, + 0.0008172249989002011, + 0.0007998250002856366, + 0.0007806166002410464, + 0.0008033165999222547, + 0.000819983400288038, + 0.0007869000008213333, + 0.0008184665988665074, + 0.000830800000403542, + 0.0007956418005051092, + 0.0007683332005399279, + 0.0007362999996985309, + 0.0008262917996034958, + 0.0007905666003352963, + 0.0007530000002589077, + 0.0007399081994662992 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/pandas_ta]", + "params": { + "indicator": "WILLR", + "library": "pandas_ta" + }, + "param": "Momentum/WILLR/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008449583998299204, + "max": 0.001058283400197979, + "mean": 0.0009021850202407222, + "stddev": 6.125078230424613e-05, + "rounds": 20, + "median": 0.0008848375000525266, + "iqr": 8.282920025521885e-05, + "q1": 0.0008555917003832292, + "q3": 0.0009384209006384481, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0008449583998299204, + "hd15iqr": 0.001058283400197979, + "ops": 1108.420088523725, + "total": 0.018043700404814445, + "data": [ + 0.0008526999998139217, + 0.0008584834009525366, + 0.0009915665999869817, + 0.0009652000007918105, + 0.0009549834008794278, + 0.000859966799907852, + 0.0008649082010379061, + 0.0008482418008497917, + 0.0008479750002152286, + 0.0008497168004396371, + 0.0008449583998299204, + 0.001004083400766831, + 0.0009218584003974683, + 0.0008902168003260158, + 0.0008806915997411124, + 0.000891958198917564, + 0.0008690082002431154, + 0.0008889834003639408, + 0.0008999165991554036, + 0.001058283400197979 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/ta]", + "params": { + "indicator": "WILLR", + "library": "ta" + }, + "param": "Momentum/WILLR/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0032448749989271164, + "max": 0.0036128249994362704, + "mean": 0.003386439599998994, + "stddev": 0.00011034151962928716, + "rounds": 20, + "median": 0.003329825000400888, + "iqr": 0.00016185409986064787, + "q1": 0.003313791700202273, + "q3": 0.0034756458000629207, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0032448749989271164, + "hd15iqr": 0.0036128249994362704, + "ops": 295.29538929331477, + "total": 0.06772879199997987, + "data": [ + 0.003485900000669062, + 0.0033974834004766308, + 0.0036128249994362704, + 0.0032448749989271164, + 0.0034653915994567797, + 0.0033229831999051383, + 0.0035184583990485407, + 0.0033263500008615665, + 0.0033256334005272946, + 0.003307800000766292, + 0.003319783399638254, + 0.0032952416004263796, + 0.003322299999126699, + 0.003253525000764057, + 0.003389233400230296, + 0.003272641800867859, + 0.0035214000003179536, + 0.003582058398751542, + 0.0033332999999402093, + 0.0034316083998419344 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/tulipy]", + "params": { + "indicator": "WILLR", + "library": "tulipy" + }, + "param": "Momentum/WILLR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007638916009454988, + "max": 0.0008114333992125467, + "mean": 0.0007777199801057577, + "stddev": 1.256495018683506e-05, + "rounds": 20, + "median": 0.0007761332999507431, + "iqr": 1.4637400454375893e-05, + "q1": 0.0007677708992559929, + "q3": 0.0007824082997103688, + "iqr_outliers": 2, + "stddev_outliers": 5, + "outliers": "5;2", + "ld15iqr": 0.0007638916009454988, + "hd15iqr": 0.0008047916009672918, + "ops": 1285.809835905226, + "total": 0.015554399602115155, + "data": [ + 0.000776274999952875, + 0.0007822081999620423, + 0.000781325000571087, + 0.0007682083989493549, + 0.0007831750001059846, + 0.000783491600304842, + 0.0007747499999823049, + 0.0007650168001418934, + 0.0007671582003240474, + 0.0007805000001098961, + 0.0007825331995263696, + 0.0008047916009672918, + 0.0007759915999486112, + 0.0007708416000241413, + 0.0007638916009454988, + 0.0007642416007001884, + 0.0007673333995626308, + 0.0008114333992125467, + 0.0007822833998943679, + 0.0007689500009291806 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/finta]", + "params": { + "indicator": "WILLR", + "library": "finta" + }, + "param": "Momentum/WILLR/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00325145840033656, + "max": 0.0036330000002635643, + "mean": 0.003480253760062624, + "stddev": 0.00011052859324123222, + "rounds": 20, + "median": 0.0035015833003853914, + "iqr": 0.00018994589918293076, + "q1": 0.003380358299909858, + "q3": 0.003570304199092789, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.00325145840033656, + "hd15iqr": 0.0036330000002635643, + "ops": 287.3353694708762, + "total": 0.06960507520125248, + "data": [ + 0.0035214249990531245, + 0.0033573834007256664, + 0.0034805000002961608, + 0.0035877416012226604, + 0.003316849999828264, + 0.003355750000628177, + 0.003502058199956082, + 0.0034541999993962236, + 0.0035719249994144776, + 0.00360144160076743, + 0.003414566599531099, + 0.0033775082003558053, + 0.003554608399281278, + 0.0036111168010393158, + 0.0036330000002635643, + 0.0035605418001068757, + 0.003501108400814701, + 0.0035686833987711, + 0.0033832083994639107, + 0.00325145840033656 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/ferro_ta]", + "params": { + "indicator": "AROON", + "library": "ferro_ta" + }, + "param": "Momentum/AROON/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.001369483399321325, + "max": 0.0015028665991849266, + "mean": 0.0013932991301408037, + "stddev": 3.0431700467012695e-05, + "rounds": 20, + "median": 0.0013881916005630047, + "iqr": 2.8150000434834467e-05, + "q1": 0.0013733125000726432, + "q3": 0.0014014625005074777, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.001369483399321325, + "hd15iqr": 0.0015028665991849266, + "ops": 717.7209677141923, + "total": 0.027865982602816076, + "data": [ + 0.0014251332002459093, + 0.0014120831998297944, + 0.0015028665991849266, + 0.0014102082001045345, + 0.0013900666002882645, + 0.0013902833990869113, + 0.001391824999882374, + 0.0014082000008784235, + 0.0013863166008377449, + 0.00137772500020219, + 0.0013731415994698182, + 0.001394725000136532, + 0.001375075000396464, + 0.0013701415999094024, + 0.0013713332009501755, + 0.0013789916003588587, + 0.0013935000009951183, + 0.001369483399321325, + 0.001373483400675468, + 0.00137140000006184 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/talib]", + "params": { + "indicator": "AROON", + "library": "talib" + }, + "param": "Momentum/AROON/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005568667998886667, + "max": 0.0007276584001374431, + "mean": 0.0005909687402163399, + "stddev": 4.295326372886126e-05, + "rounds": 20, + "median": 0.0005837458003952634, + "iqr": 3.0374999187188288e-05, + "q1": 0.0005623208002361934, + "q3": 0.0005926957994233817, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0005568667998886667, + "hd15iqr": 0.0006822499999543652, + "ops": 1692.1368795816902, + "total": 0.011819374804326798, + "data": [ + 0.0005640000003040768, + 0.0005653332002111711, + 0.0005604584002867341, + 0.0005606416001683101, + 0.0005585666003753431, + 0.0005568667998886667, + 0.0005578168013016694, + 0.0005913750006584451, + 0.0007276584001374431, + 0.0006119334007962607, + 0.0005930581988650374, + 0.0006822499999543652, + 0.0006080500010284595, + 0.0005923333999817259, + 0.0005821750004542991, + 0.0005853166003362276, + 0.0005729166005039588, + 0.0005738581996411086, + 0.0005872749999980443, + 0.0005874915994354523 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/pandas_ta]", + "params": { + "indicator": "AROON", + "library": "pandas_ta" + }, + "param": "Momentum/AROON/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012466915999539197, + "max": 0.0012952249991940335, + "mean": 0.0012623816498671659, + "stddev": 1.4269437063281408e-05, + "rounds": 20, + "median": 0.001256683300016448, + "iqr": 1.9954099116148427e-05, + "q1": 0.0012507167004514486, + "q3": 0.001270670799567597, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0012466915999539197, + "hd15iqr": 0.0012952249991940335, + "ops": 792.1534665092961, + "total": 0.025247632997343318, + "data": [ + 0.001289225000073202, + 0.0012810168002033607, + 0.0012952249991940335, + 0.0012683166001806966, + 0.0012629999997443519, + 0.0012537249989691191, + 0.0012699250000878237, + 0.001258958199468907, + 0.001249449999886565, + 0.0012496415991336107, + 0.0012491332003264689, + 0.0012706999987130985, + 0.0012706416004220956, + 0.0012525165991974063, + 0.001254408400563989, + 0.0012501168006565423, + 0.0012723000007099472, + 0.001251316600246355, + 0.0012513249996118248, + 0.0012466915999539197 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/ta]", + "params": { + "indicator": "AROON", + "library": "ta" + }, + "param": "Momentum/AROON/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.1215228583998396, + "max": 0.13249444999964907, + "mean": 0.12504381875005494, + "stddev": 0.0026974968833491544, + "rounds": 20, + "median": 0.12505546250104088, + "iqr": 0.0035595833003753963, + "q1": 0.12305150009924545, + "q3": 0.12661108339962085, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.1215228583998396, + "hd15iqr": 0.13249444999964907, + "ops": 7.997196582734409, + "total": 2.5008763750010985, + "data": [ + 0.12615110839979024, + 0.12523628339986317, + 0.12612859159999062, + 0.12843612500000745, + 0.12799144160089782, + 0.1272988250013441, + 0.13249444999964907, + 0.12518555000133347, + 0.12317749179928797, + 0.12272819159989012, + 0.12292550839920295, + 0.12520366660028232, + 0.12707105839945143, + 0.12492537500074832, + 0.12351866660028463, + 0.12326716679963283, + 0.1215228583998396, + 0.1220750581996981, + 0.12227967499929945, + 0.12325928320060484 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/tulipy]", + "params": { + "indicator": "AROON", + "library": "tulipy" + }, + "param": "Momentum/AROON/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006820082009653561, + "max": 0.0007180583997978829, + "mean": 0.0006928087401320227, + "stddev": 1.089655980111001e-05, + "rounds": 20, + "median": 0.0006882041998323984, + "iqr": 1.889999912236813e-05, + "q1": 0.0006840375004685484, + "q3": 0.0007029374995909165, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0006820082009653561, + "hd15iqr": 0.0007180583997978829, + "ops": 1443.3998044098557, + "total": 0.013856174802640453, + "data": [ + 0.0007010583998635411, + 0.000692741600505542, + 0.0006898666004417464, + 0.0006880415996420198, + 0.0006859750006697141, + 0.0006854581995867192, + 0.0006836415996076539, + 0.0006821832008427009, + 0.0007052834000205622, + 0.0007180583997978829, + 0.0007068999999319203, + 0.0006883668000227771, + 0.000685808400157839, + 0.0006968333997065202, + 0.0006820082009653561, + 0.0006822499999543652, + 0.0006841000009444542, + 0.0007088084006682038, + 0.000704816599318292, + 0.0006839749999926426 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROONOSC/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROONOSC/ferro_ta]", + "params": { + "indicator": "AROONOSC", + "library": "ferro_ta" + }, + "param": "Momentum/AROONOSC/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0014183084000251255, + "max": 0.0015243665999150834, + "mean": 0.0014498420999007066, + "stddev": 3.1066739053002475e-05, + "rounds": 20, + "median": 0.001447633399948245, + "iqr": 4.429160107974903e-05, + "q1": 0.0014223291997041087, + "q3": 0.0014666208007838577, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0014183084000251255, + "hd15iqr": 0.0015243665999150834, + "ops": 689.7302817103225, + "total": 0.028996841998014132, + "data": [ + 0.0014302999989013188, + 0.0014622834001784212, + 0.001422949999687262, + 0.0014186415995936842, + 0.001422591799928341, + 0.001435566799773369, + 0.0014201418001903222, + 0.001448050000180956, + 0.0014723665997735224, + 0.0014753165989532136, + 0.0014708500006236137, + 0.001449658199271653, + 0.0014623916009441017, + 0.0015137499998672866, + 0.0014220665994798764, + 0.0014186418004101143, + 0.0014183084000251255, + 0.001447216799715534, + 0.0015243665999150834, + 0.0014613834006013348 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROONOSC/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROONOSC/talib]", + "params": { + "indicator": "AROONOSC", + "library": "talib" + }, + "param": "Momentum/AROONOSC/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005737583996960893, + "max": 0.0006250584003282711, + "mean": 0.0005881629399664234, + "stddev": 1.3842073137240903e-05, + "rounds": 20, + "median": 0.0005825209002068732, + "iqr": 1.7979300173465076e-05, + "q1": 0.0005788957998447586, + "q3": 0.0005968751000182237, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0005737583996960893, + "hd15iqr": 0.0006250584003282711, + "ops": 1700.2091292203606, + "total": 0.011763258799328468, + "data": [ + 0.0006250584003282711, + 0.00060208320064703, + 0.0006066000001737848, + 0.0005842833998030983, + 0.0005796334007754922, + 0.0005847749998793006, + 0.0005822000006446615, + 0.0005786581998108887, + 0.0005819083991809749, + 0.0005828417997690849, + 0.0005766415997641161, + 0.0005765915993833914, + 0.000611808399844449, + 0.0005967584002064541, + 0.0005969917998299934, + 0.0005793083997559734, + 0.0005791333998786286, + 0.0005862749996595085, + 0.0005779500002972782, + 0.0005737583996960893 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROONOSC/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROONOSC/tulipy]", + "params": { + "indicator": "AROONOSC", + "library": "tulipy" + }, + "param": "Momentum/AROONOSC/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007243999993079342, + "max": 0.000933350001287181, + "mean": 0.0007813604197872337, + "stddev": 5.968612377470288e-05, + "rounds": 20, + "median": 0.0007633625995367765, + "iqr": 7.222510030260312e-05, + "q1": 0.0007333707995712757, + "q3": 0.0008055958998738789, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0007243999993079342, + "hd15iqr": 0.000933350001287181, + "ops": 1279.8191137865704, + "total": 0.015627208395744673, + "data": [ + 0.000767791600083001, + 0.0007457165993400849, + 0.0007325500002480112, + 0.0007341915988945402, + 0.0007289916000445373, + 0.0007281081998371519, + 0.0007243999993079342, + 0.0007294167997315526, + 0.0007526833986048586, + 0.0007897083996795118, + 0.0009084416000405326, + 0.0008462915997370146, + 0.000933350001287181, + 0.0008380334009416401, + 0.0008020749999559484, + 0.0008091167997918092, + 0.0007728333992417901, + 0.0007654667992028408, + 0.0007567831999040209, + 0.0007612583998707123 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/ferro_ta]", + "params": { + "indicator": "ADX", + "library": "ferro_ta" + }, + "param": "Momentum/ADX/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007825666005373932, + "max": 0.0008440833989880048, + "mean": 0.0007947512300597736, + "stddev": 1.5654096134965964e-05, + "rounds": 20, + "median": 0.0007883082995249424, + "iqr": 1.4933299098629504e-05, + "q1": 0.0007846500004234259, + "q3": 0.0007995832995220554, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0007825666005373932, + "hd15iqr": 0.0008440833989880048, + "ops": 1258.2553661789104, + "total": 0.015895024601195473, + "data": [ + 0.000794933398719877, + 0.0008440833989880048, + 0.0008218750008381903, + 0.0007845500003895722, + 0.000783416599733755, + 0.0007880749995820225, + 0.0007825666005373932, + 0.0007829581998521462, + 0.0007931250002002344, + 0.0008050165997701697, + 0.0007887999992817641, + 0.0007847500004572794, + 0.0007872250003856607, + 0.0007885415994678624, + 0.0007854416006011888, + 0.0007841334008844569, + 0.0008042332003242337, + 0.0007935250003356486, + 0.0008102500010863878, + 0.0007875249997596256 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/talib]", + "params": { + "indicator": "ADX", + "library": "talib" + }, + "param": "Momentum/ADX/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00070830819895491, + "max": 0.000744108400249388, + "mean": 0.0007191333299851976, + "stddev": 1.0426507118086933e-05, + "rounds": 20, + "median": 0.0007157499996537809, + "iqr": 1.3425000361166894e-05, + "q1": 0.0007111999999324325, + "q3": 0.0007246250002935994, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.00070830819895491, + "hd15iqr": 0.000744108400249388, + "ops": 1390.5627208525902, + "total": 0.014382666599703952, + "data": [ + 0.0007408999998006038, + 0.0007277832002728247, + 0.000744108400249388, + 0.0007237500001792796, + 0.0007185084003140218, + 0.0007172166006057523, + 0.0007169834003434517, + 0.000712633399234619, + 0.00071451659896411, + 0.000713733400334604, + 0.0007202416003565304, + 0.0007331665998208337, + 0.0007126583994249813, + 0.0007092166008078494, + 0.0007111250000889413, + 0.0007106999997631647, + 0.0007112749997759237, + 0.0007103418000042438, + 0.00070830819895491, + 0.0007255000004079193 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/pandas_ta]", + "params": { + "indicator": "ADX", + "library": "pandas_ta" + }, + "param": "Momentum/ADX/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.02613092499959748, + "max": 0.026615133399900515, + "mean": 0.026376356669861708, + "stddev": 0.00016099240529804596, + "rounds": 20, + "median": 0.02635677499929443, + "iqr": 0.00029911659948993544, + "q1": 0.02624082080074004, + "q3": 0.026539937400229974, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.02613092499959748, + "hd15iqr": 0.026615133399900515, + "ops": 37.912741798135656, + "total": 0.5275271333972341, + "data": [ + 0.026434666599379854, + 0.026203875000646804, + 0.026173216599272565, + 0.02629480000032345, + 0.026569558400660755, + 0.026292675000149757, + 0.02613092499959748, + 0.026277766600833273, + 0.02619797499937704, + 0.02660114159953082, + 0.026372308399004396, + 0.026544683199608697, + 0.026593358399986756, + 0.026484466799593064, + 0.0263784583992674, + 0.026615133399900515, + 0.026341241599584463, + 0.026308008399792016, + 0.026177683399873787, + 0.02653519160085125 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/ta]", + "params": { + "indicator": "ADX", + "library": "ta" + }, + "param": "Momentum/ADX/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.312413858200307, + "max": 0.3195473249987117, + "mean": 0.3156811116600875, + "stddev": 0.00223900625008408, + "rounds": 20, + "median": 0.31530326260035507, + "iqr": 0.003442158399411699, + "q1": 0.31386543750049894, + "q3": 0.31730759589991064, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.312413858200307, + "hd15iqr": 0.3195473249987117, + "ops": 3.1677536699654025, + "total": 6.313622233201749, + "data": [ + 0.31291124999988823, + 0.3138548000002629, + 0.31356883319967893, + 0.3161998666008003, + 0.3158537499999511, + 0.3156285168006434, + 0.31497800840006673, + 0.31471842500031927, + 0.312413858200307, + 0.3133946999994805, + 0.31729864999942947, + 0.31868933340010697, + 0.313876075000735, + 0.31721884160069747, + 0.3195473249987117, + 0.31899495839898007, + 0.3190076415994554, + 0.31731654180039187, + 0.31421771660097875, + 0.3139331416008645 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/tulipy]", + "params": { + "indicator": "ADX", + "library": "tulipy" + }, + "param": "Momentum/ADX/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006590749995666556, + "max": 0.0006869000004371628, + "mean": 0.0006677008199039847, + "stddev": 8.570670976447353e-06, + "rounds": 20, + "median": 0.0006642624000960495, + "iqr": 1.4604099123971493e-05, + "q1": 0.0006613209006900433, + "q3": 0.0006759249998140148, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0006590749995666556, + "hd15iqr": 0.0006869000004371628, + "ops": 1497.6767590966863, + "total": 0.013354016398079694, + "data": [ + 0.0006787082005757838, + 0.0006770334002794698, + 0.0006869000004371628, + 0.00067855840025004, + 0.0006648499998846092, + 0.0006646331996307709, + 0.0006647834001341834, + 0.0006638916005613282, + 0.0006620082000154071, + 0.0006624749992624856, + 0.0006596499995794147, + 0.00067481659934856, + 0.0006688833993393928, + 0.0006817165995016694, + 0.000660858400806319, + 0.0006607749994145707, + 0.0006617834005737677, + 0.0006590749995666556, + 0.0006621499996981584, + 0.0006604665992199443 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/ferro_ta]", + "params": { + "indicator": "MOM", + "library": "ferro_ta" + }, + "param": "Momentum/MOM/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001874665991635993, + "max": 0.0002098415992804803, + "mean": 0.00019470709004963283, + "stddev": 7.458290003689772e-06, + "rounds": 20, + "median": 0.0001907499994558748, + "iqr": 1.2633399455808098e-05, + "q1": 0.00018934580075438134, + "q3": 0.00020197920021018944, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0001874665991635993, + "hd15iqr": 0.0002098415992804803, + "ops": 5135.919805206322, + "total": 0.0038941418009926566, + "data": [ + 0.0002098415992804803, + 0.00020956680091330782, + 0.0002047834001132287, + 0.00020234179974067956, + 0.0002023667999310419, + 0.00020161660067969934, + 0.0001951750004081987, + 0.00019334179960424082, + 0.00019278319959994405, + 0.00018985839997185395, + 0.00018978340085595846, + 0.00019029160030186175, + 0.00019029999966733158, + 0.00018840840057237073, + 0.00018980000022565945, + 0.00018857499962905423, + 0.000191199999244418, + 0.0001874665991635993, + 0.00018890820065280421, + 0.00018773320043692366 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/talib]", + "params": { + "indicator": "MOM", + "library": "talib" + }, + "param": "Momentum/MOM/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001795834003132768, + "max": 0.00019547499978216364, + "mean": 0.00018325792014366014, + "stddev": 4.407628562215975e-06, + "rounds": 20, + "median": 0.00018156670048483645, + "iqr": 2.5040993932634646e-06, + "q1": 0.0001807042004656978, + "q3": 0.00018320829985896125, + "iqr_outliers": 4, + "stddev_outliers": 4, + "outliers": "4;4", + "ld15iqr": 0.0001795834003132768, + "hd15iqr": 0.00018840840057237073, + "ops": 5456.790076063707, + "total": 0.003665158402873203, + "data": [ + 0.00018227500113425775, + 0.00018282499950146304, + 0.00018214160081697628, + 0.00018170840048696845, + 0.00018186660017818212, + 0.00018139180028811097, + 0.00018840840057237073, + 0.00019037499878322707, + 0.00019091659924015404, + 0.00019547499978216364, + 0.00018359160021645947, + 0.00018049180071102455, + 0.0001797915989300236, + 0.0001795834003132768, + 0.00018091660022037103, + 0.0001799000005121343, + 0.00018104159971699119, + 0.00018100000015692786, + 0.00018003340082941576, + 0.00018142500048270449 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/pandas_ta]", + "params": { + "indicator": "MOM", + "library": "pandas_ta" + }, + "param": "Momentum/MOM/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002525999996578321, + "max": 0.00027151679969392715, + "mean": 0.00025761585973668846, + "stddev": 4.711641605958088e-06, + "rounds": 20, + "median": 0.00025629169904277664, + "iqr": 2.57500068983062e-06, + "q1": 0.00025540419956087134, + "q3": 0.00025797920025070196, + "iqr_outliers": 3, + "stddev_outliers": 5, + "outliers": "5;3", + "ld15iqr": 0.0002525999996578321, + "hd15iqr": 0.00026317500014556573, + "ops": 3881.7485888567157, + "total": 0.005152317194733769, + "data": [ + 0.0002566833994933404, + 0.0002562999987276271, + 0.0002564249996794388, + 0.0002553500002250075, + 0.0002570166005170904, + 0.00025619159860070797, + 0.00025574180035619064, + 0.0002562833993579261, + 0.00025545839889673516, + 0.0002525999996578321, + 0.00025495000008959325, + 0.00025594159960746766, + 0.00025894179998431355, + 0.00026740839966805653, + 0.00026317500014556573, + 0.00025364180037286134, + 0.0002592333999928087, + 0.00027151679969392715, + 0.00025268319877795874, + 0.0002567750008893199 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/tulipy]", + "params": { + "indicator": "MOM", + "library": "tulipy" + }, + "param": "Momentum/MOM/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00018034999957308173, + "max": 0.0001893499997095205, + "mean": 0.0001825637299043592, + "stddev": 2.1280875392349773e-06, + "rounds": 20, + "median": 0.00018165829969802872, + "iqr": 1.8667997210286558e-06, + "q1": 0.0001812416005122941, + "q3": 0.00018310840023332274, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.00018034999957308173, + "hd15iqr": 0.0001860000003944151, + "ops": 5477.539270937749, + "total": 0.0036512745980871843, + "data": [ + 0.00018394159997114912, + 0.00018308340077055618, + 0.00018254159949719905, + 0.00018272500019520522, + 0.000181458200677298, + 0.00018434159865137189, + 0.00018157500016968697, + 0.00018137500010197982, + 0.00018121660104952753, + 0.00018300000083399938, + 0.00018313339969608933, + 0.00018174159922637045, + 0.0001808834000257775, + 0.0001806333995773457, + 0.00018120819877367466, + 0.00018034999957308173, + 0.00018126659997506068, + 0.0001814499992178753, + 0.0001860000003944151, + 0.0001893499997095205 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/finta]", + "params": { + "indicator": "MOM", + "library": "finta" + }, + "param": "Momentum/MOM/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00034426680067554114, + "max": 0.0003692666010465473, + "mean": 0.0003492729098070413, + "stddev": 5.596595551468649e-06, + "rounds": 20, + "median": 0.0003479707993392367, + "iqr": 4.108200664632001e-06, + "q1": 0.0003458208993833978, + "q3": 0.0003499291000480298, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.00034426680067554114, + "hd15iqr": 0.0003692666010465473, + "ops": 2863.090643223542, + "total": 0.006985458196140826, + "data": [ + 0.00034853339893743397, + 0.00034980000054929405, + 0.00034759160043904556, + 0.00034595839970279484, + 0.0003492000003461726, + 0.00034545840026112273, + 0.00035462499945424495, + 0.00034426680067554114, + 0.00034824999893317, + 0.0003476915997453034, + 0.00034661660029087213, + 0.00035005819954676555, + 0.0003452999997534789, + 0.00035319159942446274, + 0.0003456833990640007, + 0.000354416600021068, + 0.0003692666010465473, + 0.00034870839881477876, + 0.000346041600278113, + 0.0003447999988566153 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/ferro_ta]", + "params": { + "indicator": "ROC", + "library": "ferro_ta" + }, + "param": "Momentum/ROC/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005732165998779237, + "max": 0.0006108665998908691, + "mean": 0.0005849749800836434, + "stddev": 1.028423994690457e-05, + "rounds": 20, + "median": 0.0005829249996168074, + "iqr": 1.8108398944605185e-05, + "q1": 0.0005760666004789527, + "q3": 0.0005941749994235578, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005732165998779237, + "hd15iqr": 0.0006108665998908691, + "ops": 1709.4748220804481, + "total": 0.011699499601672868, + "data": [ + 0.0005828416004078462, + 0.000581975000386592, + 0.0005754249999881722, + 0.0005830083988257684, + 0.0005765499998233281, + 0.0005961084010777995, + 0.0005960833994322456, + 0.0005959415997494943, + 0.0005760915999417193, + 0.0005855000010342338, + 0.0005741665998357348, + 0.0005760416010161862, + 0.0005830166002851911, + 0.0005732165998779237, + 0.0005744750000303611, + 0.0005780584004241973, + 0.0005945749988313764, + 0.0006108665998908691, + 0.0005937750000157393, + 0.0005917832007980905 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/talib]", + "params": { + "indicator": "ROC", + "library": "talib" + }, + "param": "Momentum/ROC/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00020150819909758866, + "max": 0.00021743339893873782, + "mean": 0.00020540749974315987, + "stddev": 4.256377591251137e-06, + "rounds": 20, + "median": 0.00020329589970060624, + "iqr": 4.537498898571368e-06, + "q1": 0.00020265000057406723, + "q3": 0.0002071874994726386, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00020150819909758866, + "hd15iqr": 0.00021743339893873782, + "ops": 4868.371414142099, + "total": 0.004108149994863197, + "data": [ + 0.00020322500058682634, + 0.00020323339995229617, + 0.00020261659956304355, + 0.00020331679988885298, + 0.0002027833994361572, + 0.00020236660056980327, + 0.00020264160120859743, + 0.00020265839993953704, + 0.0002066999993985519, + 0.00020436659979168327, + 0.00020428339921636508, + 0.00020150819909758866, + 0.00020244999905116856, + 0.0002032749995123595, + 0.00020812500006286427, + 0.00021144999918760733, + 0.0002134331996785477, + 0.00021743339893873782, + 0.00020767499954672531, + 0.00020460840023588388 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/pandas_ta]", + "params": { + "indicator": "ROC", + "library": "pandas_ta" + }, + "param": "Momentum/ROC/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002709918000618927, + "max": 0.0002925667999079451, + "mean": 0.0002791746000002604, + "stddev": 6.394852125715163e-06, + "rounds": 20, + "median": 0.00027857509994646536, + "iqr": 8.070799231063596e-06, + "q1": 0.0002734250003413763, + "q3": 0.0002814957995724399, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0002709918000618927, + "hd15iqr": 0.0002925667999079451, + "ops": 3581.9877596280867, + "total": 0.005583492000005208, + "data": [ + 0.00029130819893907756, + 0.00027853339997818694, + 0.00027717500051949173, + 0.0002788249999866821, + 0.0002724168007262051, + 0.0002813415994751267, + 0.00027894160011783243, + 0.0002773416010313667, + 0.0002722165998420678, + 0.0002782668001600541, + 0.0002733000001171604, + 0.00027134179981658235, + 0.00028164999966975304, + 0.0002735500005655922, + 0.0002709918000618927, + 0.00027861679991474374, + 0.00028000000020256266, + 0.000288208200072404, + 0.0002925667999079451, + 0.0002868999989004806 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/ta]", + "params": { + "indicator": "ROC", + "library": "ta" + }, + "param": "Momentum/ROC/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003523168008541688, + "max": 0.0004730334010673687, + "mean": 0.000376715419915854, + "stddev": 2.8545009554807095e-05, + "rounds": 20, + "median": 0.0003659583002445288, + "iqr": 3.3991799864452376e-05, + "q1": 0.0003577498995582573, + "q3": 0.00039174169942270967, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0003523168008541688, + "hd15iqr": 0.0004730334010673687, + "ops": 2654.5236725997775, + "total": 0.00753430839831708, + "data": [ + 0.0003580499993404374, + 0.00035350839898455887, + 0.0003579415992135182, + 0.0003567166000721045, + 0.0003575581999029964, + 0.00035465000109979883, + 0.000358083400351461, + 0.0003625084005761892, + 0.0003523168008541688, + 0.0003683499991893768, + 0.0003829665991361253, + 0.00038340820028679443, + 0.00039680839981883766, + 0.0003955749998567626, + 0.0004730334010673687, + 0.0004074333992321044, + 0.00038840839988552034, + 0.00039507499895989894, + 0.00036749999999301507, + 0.00036441660049604254 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/tulipy]", + "params": { + "indicator": "ROC", + "library": "tulipy" + }, + "param": "Momentum/ROC/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00020316659938544035, + "max": 0.00022015839931555092, + "mean": 0.00021101706988702063, + "stddev": 4.937421930803606e-06, + "rounds": 20, + "median": 0.00021103740000398828, + "iqr": 8.84159962879496e-06, + "q1": 0.00020706249997601842, + "q3": 0.00021590409960481338, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.00020316659938544035, + "hd15iqr": 0.00022015839931555092, + "ops": 4738.953111875755, + "total": 0.0042203413977404125, + "data": [ + 0.00021633340074913576, + 0.00021617500024149195, + 0.00021161659969948232, + 0.00020700000022770836, + 0.0002066332002868876, + 0.00021146660001249983, + 0.0002091999995172955, + 0.00020386679971124978, + 0.00020409160060808063, + 0.00021604159992421045, + 0.0002165333993616514, + 0.00021576659928541632, + 0.0002149499996448867, + 0.00022015839931555092, + 0.0002071249997243285, + 0.00020316659938544035, + 0.00020715839928016068, + 0.00021060819999547676, + 0.00021186660014791415, + 0.00021058340062154456 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/finta]", + "params": { + "indicator": "ROC", + "library": "finta" + }, + "param": "Momentum/ROC/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00046379179984796793, + "max": 0.0005776834004791453, + "mean": 0.0005007562698301626, + "stddev": 3.3027066650140045e-05, + "rounds": 20, + "median": 0.0004883915993559641, + "iqr": 4.884170048171662e-05, + "q1": 0.000477383299585199, + "q3": 0.0005262250000669156, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00046379179984796793, + "hd15iqr": 0.0005776834004791453, + "ops": 1996.9794893215449, + "total": 0.010015125396603253, + "data": [ + 0.00046756679948884995, + 0.00048085839953273537, + 0.0004818831992452033, + 0.00046825820027152076, + 0.00046379179984796793, + 0.00047448339901166035, + 0.000489674998971168, + 0.0004917584010399878, + 0.0005234582000412047, + 0.0005152166006155312, + 0.0005776834004791453, + 0.0005492500000400469, + 0.0005403167990152725, + 0.0005289918000926264, + 0.0004901667998638004, + 0.0005495083998539485, + 0.00048038340028142554, + 0.0004745749989524484, + 0.00048710819974076005, + 0.0004801916002179496 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/ferro_ta]", + "params": { + "indicator": "CMO", + "library": "ferro_ta" + }, + "param": "Momentum/CMO/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008705168002052233, + "max": 0.0009080666000954807, + "mean": 0.0008871371100394753, + "stddev": 1.1517701573121492e-05, + "rounds": 20, + "median": 0.0008849833000567741, + "iqr": 2.1204100630711807e-05, + "q1": 0.0008761001001403202, + "q3": 0.000897304200771032, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0008705168002052233, + "hd15iqr": 0.0009080666000954807, + "ops": 1127.2214730770336, + "total": 0.01774274220078951, + "data": [ + 0.0008838915993692354, + 0.0008749165994231589, + 0.0008705168002052233, + 0.0008884668000973761, + 0.0008991416005301289, + 0.0008740833989577367, + 0.0008836834007524885, + 0.0008729334003874101, + 0.0008786000005784444, + 0.0008761584002058953, + 0.0008911167999031023, + 0.000898391600640025, + 0.000894583399349358, + 0.0008839165995595977, + 0.000876041800074745, + 0.0009080666000954807, + 0.0009046749997651205, + 0.0008962168009020388, + 0.0009012915994389914, + 0.0008860500005539506 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/talib]", + "params": { + "indicator": "CMO", + "library": "talib" + }, + "param": "Momentum/CMO/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006336250007734634, + "max": 0.0007171749995904975, + "mean": 0.0006655862698971759, + "stddev": 2.3764854657654948e-05, + "rounds": 20, + "median": 0.0006584249000297858, + "iqr": 2.112919974024414e-05, + "q1": 0.0006508708996989298, + "q3": 0.000672000099439174, + "iqr_outliers": 2, + "stddev_outliers": 5, + "outliers": "5;2", + "ld15iqr": 0.0006336250007734634, + "hd15iqr": 0.0007157917993026785, + "ops": 1502.4348386190215, + "total": 0.013311725397943518, + "data": [ + 0.000650649999442976, + 0.0006519665999803692, + 0.0006336250007734634, + 0.0006534000000101514, + 0.0006666167988441885, + 0.0007171749995904975, + 0.0006606915994780138, + 0.0006585832001292147, + 0.0006454083995777182, + 0.0006610165990423411, + 0.00065606660064077, + 0.000645941800030414, + 0.0006975834010518156, + 0.0007157917993026785, + 0.000701100000878796, + 0.0006773834000341594, + 0.0006582665999303571, + 0.0006489499995950609, + 0.0006510917999548837, + 0.0006604167996556498 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/pandas_ta]", + "params": { + "indicator": "CMO", + "library": "pandas_ta" + }, + "param": "Momentum/CMO/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000703675000113435, + "max": 0.0008264999996754341, + "mean": 0.0007384612700116122, + "stddev": 3.1877089828718824e-05, + "rounds": 20, + "median": 0.0007330957996600773, + "iqr": 2.2483300563180797e-05, + "q1": 0.0007202000000688713, + "q3": 0.0007426833006320521, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.000703675000113435, + "hd15iqr": 0.0008202583994716406, + "ops": 1354.1671589415585, + "total": 0.014769225400232244, + "data": [ + 0.0007353584005613811, + 0.000737866600684356, + 0.0007315915994695388, + 0.0007345999998506159, + 0.0007223999986308627, + 0.0007124499999918044, + 0.000703675000113435, + 0.0007122831986634992, + 0.0008264999996754341, + 0.0008202583994716406, + 0.0007580834004329518, + 0.0007239168000523933, + 0.0007239918006234803, + 0.0007184168003732339, + 0.0007215000005089678, + 0.0007188999996287748, + 0.0007425500007229857, + 0.0007452917998307385, + 0.0007428166005411186, + 0.0007367750004050322 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/tulipy]", + "params": { + "indicator": "CMO", + "library": "tulipy" + }, + "param": "Momentum/CMO/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000310491600248497, + "max": 0.00033132499956991526, + "mean": 0.0003180750001774868, + "stddev": 7.010536234222882e-06, + "rounds": 20, + "median": 0.0003153666009893641, + "iqr": 1.1208400974283038e-05, + "q1": 0.0003120166991720907, + "q3": 0.00032322510014637373, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.000310491600248497, + "hd15iqr": 0.00033132499956991526, + "ops": 3143.912597475429, + "total": 0.006361500003549736, + "data": [ + 0.0003299916003015824, + 0.0003242000006139278, + 0.00031591660081176087, + 0.0003309000006993301, + 0.0003143166002701037, + 0.0003213666001101956, + 0.00031059160100994633, + 0.0003228583998861723, + 0.00033132499956991526, + 0.0003235918004065752, + 0.0003185584006132558, + 0.0003109999990556389, + 0.00031199999939417465, + 0.00031835000118007883, + 0.0003120333989500068, + 0.0003148166011669673, + 0.000310491600248497, + 0.0003108667995547876, + 0.0003148083997075446, + 0.00031351659999927505 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/finta]", + "params": { + "indicator": "CMO", + "library": "finta" + }, + "param": "Momentum/CMO/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0022429917997214945, + "max": 0.002608574999612756, + "mean": 0.0023051533199031837, + "stddev": 8.114496390872666e-05, + "rounds": 20, + "median": 0.002277404199412558, + "iqr": 6.779989998904066e-05, + "q1": 0.0022596625000005587, + "q3": 0.0023274623999895994, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0022429917997214945, + "hd15iqr": 0.002608574999612756, + "ops": 433.8106239466969, + "total": 0.04610306639806368, + "data": [ + 0.002265158199588768, + 0.00230511679983465, + 0.0023457000002963468, + 0.0022598249997827224, + 0.00227561679930659, + 0.0023558750006486663, + 0.002608574999612756, + 0.0023205831996165214, + 0.0022590250009670854, + 0.002284058400255162, + 0.0022599415999138726, + 0.0022459581989096476, + 0.0022677000000840054, + 0.002259500000218395, + 0.002365916600683704, + 0.0023191665997728704, + 0.002248824998969212, + 0.0022791915995185263, + 0.0023343416003626773, + 0.0022429917997214945 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/ferro_ta]", + "params": { + "indicator": "PPO", + "library": "ferro_ta" + }, + "param": "Momentum/PPO/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00038585000002058225, + "max": 0.00041049160063266754, + "mean": 0.0003929562001576414, + "stddev": 7.182543838519843e-06, + "rounds": 20, + "median": 0.0003912249994755257, + "iqr": 9.208499977830862e-06, + "q1": 0.0003870124004606623, + "q3": 0.00039622090043849316, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00038585000002058225, + "hd15iqr": 0.00041049160063266754, + "ops": 2544.8128814326687, + "total": 0.007859124003152829, + "data": [ + 0.00039381660026265307, + 0.00039756659971317275, + 0.00041049160063266754, + 0.00039304160018218683, + 0.00038950820016907527, + 0.0003955584004870616, + 0.00039688340038992467, + 0.00039054159860825167, + 0.00038696660049026833, + 0.0003870582004310563, + 0.00038613320066360757, + 0.00038585000002058225, + 0.0003859916003420949, + 0.0003919084003427997, + 0.0004069415997946635, + 0.0004033416000311263, + 0.0003900082010659389, + 0.0003862500001559965, + 0.00039420839893864466, + 0.0003870582004310563 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/talib]", + "params": { + "indicator": "PPO", + "library": "talib" + }, + "param": "Momentum/PPO/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005083249998278916, + "max": 0.0005590333996224218, + "mean": 0.000522359559981851, + "stddev": 1.1521384078510251e-05, + "rounds": 20, + "median": 0.0005218582991801668, + "iqr": 1.0345799091737696e-05, + "q1": 0.0005146917006641161, + "q3": 0.0005250374997558538, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0005083249998278916, + "hd15iqr": 0.0005590333996224218, + "ops": 1914.390156915563, + "total": 0.010447191199637018, + "data": [ + 0.0005157581996172667, + 0.0005133083992404863, + 0.0005098915993585251, + 0.0005146416006027721, + 0.0005151418008608744, + 0.0005238832003669813, + 0.0005228665992035531, + 0.0005242168001132086, + 0.0005115666004712694, + 0.0005083249998278916, + 0.0005147418007254601, + 0.0005185166010051034, + 0.0005208499991567805, + 0.0005241165999905206, + 0.0005323749996023252, + 0.0005258581993984989, + 0.0005240915998001583, + 0.0005590333996224218, + 0.000533749999885913, + 0.0005342582007870078 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/pandas_ta]", + "params": { + "indicator": "PPO", + "library": "pandas_ta" + }, + "param": "Momentum/PPO/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010095416000694968, + "max": 0.0012498834010330028, + "mean": 0.0011132995702064364, + "stddev": 7.611714127019214e-05, + "rounds": 20, + "median": 0.001116179199743783, + "iqr": 0.00013546249974751836, + "q1": 0.0010420375001558568, + "q3": 0.0011774999999033752, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0010095416000694968, + "hd15iqr": 0.0012498834010330028, + "ops": 898.2308327080128, + "total": 0.022265991404128726, + "data": [ + 0.0010273416002746672, + 0.0010348916010116227, + 0.0012015082000289112, + 0.001174583200190682, + 0.0012498834010330028, + 0.0011318666001898237, + 0.0010399916005553677, + 0.0010458000004291534, + 0.001109174999874085, + 0.0010095416000694968, + 0.0011629084008745849, + 0.0010352582001360133, + 0.0011804167996160686, + 0.0012156249998952263, + 0.0011260166007559746, + 0.0012227750004967675, + 0.0010561168004642242, + 0.001044083399756346, + 0.001123183399613481, + 0.0010750249988632278 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/tulipy]", + "params": { + "indicator": "PPO", + "library": "tulipy" + }, + "param": "Momentum/PPO/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00036158319999231027, + "max": 0.0009160750007140451, + "mean": 0.00043860249024874065, + "stddev": 0.0001234664951004206, + "rounds": 20, + "median": 0.0003997541003627703, + "iqr": 3.0229200638132137e-05, + "q1": 0.0003903332995832898, + "q3": 0.00042056250022142193, + "iqr_outliers": 4, + "stddev_outliers": 1, + "outliers": "1;4", + "ld15iqr": 0.00036158319999231027, + "hd15iqr": 0.0005043500001193024, + "ops": 2279.968815117486, + "total": 0.008772049804974813, + "data": [ + 0.00036158319999231027, + 0.0003632084000855684, + 0.0009160750007140451, + 0.0005416082000010647, + 0.00040490840037818996, + 0.00042967500048689543, + 0.0003927334008039907, + 0.0004015168000478297, + 0.00040034160047071056, + 0.0003919581999070942, + 0.00041144999995594843, + 0.0003887083992594853, + 0.0005043500001193024, + 0.0003956750006182119, + 0.0003986416006227955, + 0.0005273581991787069, + 0.0003646916011348367, + 0.00039916660025482995, + 0.00040203340031439436, + 0.0003763668006286025 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/finta]", + "params": { + "indicator": "PPO", + "library": "finta" + }, + "param": "Momentum/PPO/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002286949999688659, + "max": 0.0025454249989707023, + "mean": 0.0023750178999034687, + "stddev": 8.154834891870603e-05, + "rounds": 20, + "median": 0.0023491292005928697, + "iqr": 0.00013560430015786568, + "q1": 0.0023027956995065324, + "q3": 0.002438399999664398, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.002286949999688659, + "hd15iqr": 0.0025454249989707023, + "ops": 421.04945821277573, + "total": 0.04750035799806938, + "data": [ + 0.0023470584012102334, + 0.002302483200037386, + 0.002351199999975506, + 0.002516641600232106, + 0.0024282918006065302, + 0.002316966599028092, + 0.0023197581991553306, + 0.002314766601193696, + 0.0024520250008208677, + 0.0024485081987222655, + 0.002363158400112297, + 0.0025454249989707023, + 0.002425741599290632, + 0.0024705749994609503, + 0.002296283400210086, + 0.0022917584006791002, + 0.0024233083997387437, + 0.0023031081989756787, + 0.002296349999960512, + 0.002286949999688659 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/ferro_ta]", + "params": { + "indicator": "TRIX", + "library": "ferro_ta" + }, + "param": "Momentum/TRIX/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00047934160102158785, + "max": 0.0005070500003057532, + "mean": 0.00048665662994608284, + "stddev": 6.984821122236118e-06, + "rounds": 20, + "median": 0.0004840708003030159, + "iqr": 5.4083997383713505e-06, + "q1": 0.0004824124000151642, + "q3": 0.00048782079975353556, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.00047934160102158785, + "hd15iqr": 0.0004997915995772928, + "ops": 2054.836898268068, + "total": 0.009733132598921657, + "data": [ + 0.0004884499998297542, + 0.00048719159967731686, + 0.0004997915995772928, + 0.0004943166000884958, + 0.00048555819957982747, + 0.0004824082003324293, + 0.00048436659999424593, + 0.0004821668000658974, + 0.0004824165996978991, + 0.0004828165998333134, + 0.0004844668001169339, + 0.0004811915991012938, + 0.00047934160102158785, + 0.00048111659998539835, + 0.00048255820001941174, + 0.0004934249998768791, + 0.0005070500003057532, + 0.00048707499954616653, + 0.0004836499996599741, + 0.0004837750006117858 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/talib]", + "params": { + "indicator": "TRIX", + "library": "talib" + }, + "param": "Momentum/TRIX/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007628831997863017, + "max": 0.0008056581995333545, + "mean": 0.0007738137599517358, + "stddev": 1.2858489258549825e-05, + "rounds": 20, + "median": 0.000767741600429872, + "iqr": 1.4145798922982154e-05, + "q1": 0.0007646292004210408, + "q3": 0.000778774999344023, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0007628831997863017, + "hd15iqr": 0.0008024084003409371, + "ops": 1292.3006177382679, + "total": 0.015476275199034717, + "data": [ + 0.0007699084002524614, + 0.0007673500003875233, + 0.0007901499993749894, + 0.0008024084003409371, + 0.0008056581995333545, + 0.0007715667990851216, + 0.00076730840082746, + 0.0007660667994059623, + 0.0007681332004722208, + 0.0007640333991730585, + 0.0007786165995639748, + 0.0007752666002488695, + 0.0007643083998118527, + 0.0007645083998795599, + 0.0007628831997863017, + 0.0007636584006831982, + 0.0007647500009625219, + 0.0007650750005268492, + 0.000778933399124071, + 0.0007856915995944292 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/pandas_ta]", + "params": { + "indicator": "TRIX", + "library": "pandas_ta" + }, + "param": "Momentum/TRIX/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0017738166003255173, + "max": 0.002085041800455656, + "mean": 0.0018278883500170197, + "stddev": 8.54246923148818e-05, + "rounds": 20, + "median": 0.0017988833002164028, + "iqr": 5.2466800843831065e-05, + "q1": 0.0017805749994295184, + "q3": 0.0018330418002733494, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0017738166003255173, + "hd15iqr": 0.0020403667993377896, + "ops": 547.0793661936129, + "total": 0.03655776700034039, + "data": [ + 0.001799366599880159, + 0.0018001166012254544, + 0.0017805249997763894, + 0.001779341600195039, + 0.0017993999994359911, + 0.0017834333993960172, + 0.0017826999988756142, + 0.0018119168002158404, + 0.0018541668003308586, + 0.001779983400774654, + 0.001779250000254251, + 0.0017984000005526468, + 0.0017806249990826473, + 0.0017738166003255173, + 0.002085041800455656, + 0.0020403667993377896, + 0.001873774999694433, + 0.0018566582002677023, + 0.0018088084005285054, + 0.001790074999735225 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/ta]", + "params": { + "indicator": "TRIX", + "library": "ta" + }, + "param": "Momentum/TRIX/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0018672165999305435, + "max": 0.0020900917996186765, + "mean": 0.001929315830129781, + "stddev": 6.519845812273049e-05, + "rounds": 20, + "median": 0.0018982333000167272, + "iqr": 8.687910012668016e-05, + "q1": 0.0018829209002433345, + "q3": 0.0019698000003700146, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0018672165999305435, + "hd15iqr": 0.0020900917996186765, + "ops": 518.3184548549173, + "total": 0.038586316602595615, + "data": [ + 0.001877233199775219, + 0.0018851000000722705, + 0.0018944081995869056, + 0.0018868915998609737, + 0.0018806499996571802, + 0.001971316599519923, + 0.001936691800074186, + 0.0018901418006862514, + 0.001921783400757704, + 0.0018807418004143984, + 0.0018979332002345473, + 0.0019059916012338363, + 0.0018985333997989073, + 0.0018672165999305435, + 0.0018728750001173466, + 0.0019792831997619944, + 0.0020900917996186765, + 0.0019682834012201057, + 0.002020541600359138, + 0.002060608399915509 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/tulipy]", + "params": { + "indicator": "TRIX", + "library": "tulipy" + }, + "param": "Momentum/TRIX/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000425041800190229, + "max": 0.0004840583991608582, + "mean": 0.0004373366798972711, + "stddev": 1.3017255071969323e-05, + "rounds": 20, + "median": 0.0004330625000875443, + "iqr": 1.0933299927273744e-05, + "q1": 0.0004303875000914559, + "q3": 0.00044132080001872963, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.000425041800190229, + "hd15iqr": 0.0004840583991608582, + "ops": 2286.5678685695802, + "total": 0.008746733597945422, + "data": [ + 0.0004325168003560975, + 0.00044010819983668624, + 0.000443000000086613, + 0.00043628319981507956, + 0.0004348417991423048, + 0.00042664999928092583, + 0.0004312831995775923, + 0.00042832500039367003, + 0.00043960000039078295, + 0.0004323418004787527, + 0.0004322749999118969, + 0.00042626659997040405, + 0.00043360819981899115, + 0.000425041800190229, + 0.00042949180060531945, + 0.0004320831998484209, + 0.00044384179927874355, + 0.00045258339960128067, + 0.000442533400200773, + 0.0004840583991608582 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/finta]", + "params": { + "indicator": "TRIX", + "library": "finta" + }, + "param": "Momentum/TRIX/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0017655999996350146, + "max": 0.0019041249994188546, + "mean": 0.0018084087598981568, + "stddev": 3.459924289492348e-05, + "rounds": 20, + "median": 0.0018037791996903252, + "iqr": 4.5558300189441027e-05, + "q1": 0.0017808666998462286, + "q3": 0.0018264250000356696, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0017655999996350146, + "hd15iqr": 0.0019041249994188546, + "ops": 552.9723269291819, + "total": 0.036168175197963136, + "data": [ + 0.0018652334008947946, + 0.0018038665992207825, + 0.0017757666006218641, + 0.0017888083995785565, + 0.0017987083992920816, + 0.0019041249994188546, + 0.001822041800187435, + 0.0018308081998839043, + 0.00178169999999227, + 0.0017737249989295378, + 0.0018089416000293568, + 0.00183942500007106, + 0.001803691800159868, + 0.001780033399700187, + 0.0017924833999131806, + 0.001768733399512712, + 0.0017655999996350146, + 0.0018163250002544372, + 0.0018145332011044956, + 0.001833624999562744 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TSF/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TSF/ferro_ta]", + "params": { + "indicator": "TSF", + "library": "ferro_ta" + }, + "param": "Momentum/TSF/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0014891582002746872, + "max": 0.0015583250002237036, + "mean": 0.0015040954000141936, + "stddev": 1.6717074571349935e-05, + "rounds": 20, + "median": 0.0014978499995777382, + "iqr": 1.6704100562492342e-05, + "q1": 0.001493691699579358, + "q3": 0.0015103958001418504, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0014891582002746872, + "hd15iqr": 0.0015583250002237036, + "ops": 664.8514449220197, + "total": 0.030081908000283875, + "data": [ + 0.0014940667999326252, + 0.0014891582002746872, + 0.0015299165999749676, + 0.0015121499993256294, + 0.0014896416003466583, + 0.0015015249999123625, + 0.0015203000002657063, + 0.0014956334009184502, + 0.001493316599226091, + 0.0014918084008968436, + 0.0015093166002770886, + 0.0015070333989569916, + 0.001496383199992124, + 0.0014903416013112292, + 0.0014989749994128942, + 0.0015114750000066123, + 0.0014971833996241912, + 0.0014968415998737328, + 0.0014985165995312854, + 0.0015583250002237036 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TSF/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TSF/talib]", + "params": { + "indicator": "TSF", + "library": "talib" + }, + "param": "Momentum/TSF/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006686417997116223, + "max": 0.0006941915999050252, + "mean": 0.0006773591600358486, + "stddev": 6.8613734264403555e-06, + "rounds": 20, + "median": 0.0006759917996532749, + "iqr": 6.358300015563102e-06, + "q1": 0.0006727666004735511, + "q3": 0.0006791249004891142, + "iqr_outliers": 3, + "stddev_outliers": 4, + "outliers": "4;3", + "ld15iqr": 0.0006686417997116223, + "hd15iqr": 0.0006902915993123315, + "ops": 1476.3216606490948, + "total": 0.013547183200716972, + "data": [ + 0.0006738418000168167, + 0.0006766581995179876, + 0.0006710665998980403, + 0.0006803166004829108, + 0.0006941915999050252, + 0.000676016800571233, + 0.0006761249998817221, + 0.0006715833995258435, + 0.0006686417997116223, + 0.0006779332004953175, + 0.0006723832004354336, + 0.0006731500005116686, + 0.0006748416009941139, + 0.0006902915993123315, + 0.0006807082012528553, + 0.0006903834000695497, + 0.0006762749995687045, + 0.0006750833999831229, + 0.0006759667987353169, + 0.0006717249998473562 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TSF/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TSF/tulipy]", + "params": { + "indicator": "TSF", + "library": "tulipy" + }, + "param": "Momentum/TSF/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003565749997505918, + "max": 0.0003744916000869125, + "mean": 0.0003630899700510781, + "stddev": 5.883771477622556e-06, + "rounds": 20, + "median": 0.0003602458004024811, + "iqr": 9.983299969462656e-06, + "q1": 0.0003583457997592632, + "q3": 0.00036832909972872585, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0003565749997505918, + "hd15iqr": 0.0003744916000869125, + "ops": 2754.138319654833, + "total": 0.0072617994010215625, + "data": [ + 0.0003679000001284294, + 0.0003727916002389975, + 0.0003744916000869125, + 0.00036035840021213516, + 0.00035883320088032634, + 0.0003586165999877267, + 0.0003601332005928271, + 0.0003687581993290223, + 0.0003598416005843319, + 0.00035950000019511206, + 0.0003580749995307997, + 0.0003565749997505918, + 0.00035723339969990776, + 0.00035706680064322426, + 0.0003659750000224449, + 0.00035670819925144316, + 0.00036362500104587526, + 0.0003643667994765565, + 0.0003693831997225061, + 0.00037156659964239226 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ULTOSC/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ULTOSC/ferro_ta]", + "params": { + "indicator": "ULTOSC", + "library": "ferro_ta" + }, + "param": "Momentum/ULTOSC/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0020500166006968356, + "max": 0.002237850001256447, + "mean": 0.002097895009937929, + "stddev": 4.822756207424809e-05, + "rounds": 20, + "median": 0.002085870899463771, + "iqr": 5.284159997245288e-05, + "q1": 0.0020589416999428067, + "q3": 0.0021117832999152596, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0020500166006968356, + "hd15iqr": 0.002237850001256447, + "ops": 476.66827713632216, + "total": 0.04195790019875858, + "data": [ + 0.002072858400060795, + 0.002098116600245703, + 0.0020557417999953033, + 0.0021158415998797863, + 0.0020991832003346643, + 0.0020500166006968356, + 0.0020831333997193722, + 0.002059191800071858, + 0.0020578333991579712, + 0.0020728667994262652, + 0.0020586915998137556, + 0.002051883398962673, + 0.0020763166001415813, + 0.0020886083992081696, + 0.002107724999950733, + 0.002128916600486264, + 0.002237850001256447, + 0.0021587249997537584, + 0.0021051415998954324, + 0.0021792583997012117 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ULTOSC/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ULTOSC/talib]", + "params": { + "indicator": "ULTOSC", + "library": "talib" + }, + "param": "Momentum/ULTOSC/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006138750002719461, + "max": 0.0006391831993823871, + "mean": 0.0006263729000056628, + "stddev": 7.535996214289021e-06, + "rounds": 20, + "median": 0.0006272749989875593, + "iqr": 1.326679994235753e-05, + "q1": 0.0006187958002556116, + "q3": 0.0006320626001979691, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0006138750002719461, + "hd15iqr": 0.0006391831993823871, + "ops": 1596.4930794275413, + "total": 0.012527458000113257, + "data": [ + 0.0006356167999911122, + 0.0006391831993823871, + 0.000633633199322503, + 0.0006314666010439396, + 0.0006256666005356237, + 0.0006303749993094243, + 0.0006249500002013519, + 0.0006193665991304443, + 0.0006312166005955078, + 0.0006266749987844378, + 0.0006334999998216517, + 0.0006314918005955406, + 0.0006326333998003975, + 0.0006278749991906807, + 0.0006222915995749645, + 0.0006138750002719461, + 0.0006175166010507383, + 0.0006177166011184454, + 0.0006141833990113809, + 0.0006182250013807789 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ULTOSC/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ULTOSC/ta]", + "params": { + "indicator": "ULTOSC", + "library": "ta" + }, + "param": "Momentum/ULTOSC/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.01351567499950761, + "max": 0.014012683399778325, + "mean": 0.013733944169871393, + "stddev": 0.00011490187969080973, + "rounds": 20, + "median": 0.013749662499321857, + "iqr": 0.00010492489964235643, + "q1": 0.013686337599938269, + "q3": 0.013791262499580625, + "iqr_outliers": 3, + "stddev_outliers": 5, + "outliers": "5;3", + "ld15iqr": 0.013560283400875051, + "hd15iqr": 0.014012683399778325, + "ops": 72.81229540700573, + "total": 0.27467888339742785, + "data": [ + 0.013526349999301602, + 0.01351567499950761, + 0.013560283400875051, + 0.013867466599913314, + 0.013809416598815006, + 0.01377034180040937, + 0.013804033200722187, + 0.013754499999049586, + 0.013730600000417325, + 0.013702108401048463, + 0.01376826679916121, + 0.014012683399778325, + 0.013797274998796637, + 0.013712225000199396, + 0.013670566798828077, + 0.013645016599912196, + 0.013759041600860656, + 0.0137429581998731, + 0.013785250000364613, + 0.013744824999594129 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ULTOSC/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ULTOSC/tulipy]", + "params": { + "indicator": "ULTOSC", + "library": "tulipy" + }, + "param": "Momentum/ULTOSC/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005771083990111947, + "max": 0.0005984749994240701, + "mean": 0.0005843562600057339, + "stddev": 5.6281566679210384e-06, + "rounds": 20, + "median": 0.0005823874998895917, + "iqr": 7.974899926921396e-06, + "q1": 0.0005805542001326102, + "q3": 0.0005885291000595316, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0005771083990111947, + "hd15iqr": 0.0005984749994240701, + "ops": 1711.2848247577388, + "total": 0.011687125200114678, + "data": [ + 0.000581225000496488, + 0.0005818833989906125, + 0.0005798833997687324, + 0.0005816000004415401, + 0.0005827916000271216, + 0.0005830499998410232, + 0.0005984749994240701, + 0.0005880416007130407, + 0.0005813499999931082, + 0.0005827000000863336, + 0.0005796416007797234, + 0.0005796584009658545, + 0.0005834084004163742, + 0.0005793333999463357, + 0.0005771083990111947, + 0.0005820749996928498, + 0.00059027499955846, + 0.0005937584006460384, + 0.0005890165994060226, + 0.0005918499999097548 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/BOP/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/BOP/ferro_ta]", + "params": { + "indicator": "BOP", + "library": "ferro_ta" + }, + "param": "Momentum/BOP/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002463582000928, + "max": 0.0002629084003274329, + "mean": 0.0002501195499644382, + "stddev": 4.1155900247477305e-06, + "rounds": 20, + "median": 0.0002483999000105541, + "iqr": 2.7540998416952897e-06, + "q1": 0.00024779169980320147, + "q3": 0.00025054579964489676, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.0002463582000928, + "hd15iqr": 0.00025615839986130594, + "ops": 3998.0881148322046, + "total": 0.0050023909992887635, + "data": [ + 0.0002487000005203299, + 0.00025081659987336025, + 0.0002490750004653819, + 0.0002498000001651235, + 0.00024853319919202475, + 0.0002502749994164333, + 0.00024785839923424646, + 0.00024769159936113285, + 0.0002517332002753392, + 0.0002482081996276975, + 0.0002472499996656552, + 0.0002477250003721565, + 0.0002463582000928, + 0.0002573165998910554, + 0.00025615839986130594, + 0.0002629084003274329, + 0.00024826660082908345, + 0.0002480750001268461, + 0.0002481415998772718, + 0.000247500000114087 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/BOP/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/BOP/talib]", + "params": { + "indicator": "BOP", + "library": "talib" + }, + "param": "Momentum/BOP/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00022670819889754058, + "max": 0.00025359160063089805, + "mean": 0.00023103166015062015, + "stddev": 6.338756129401035e-06, + "rounds": 20, + "median": 0.00022895000074640848, + "iqr": 1.4624994946643602e-06, + "q1": 0.00022830000016256237, + "q3": 0.00022976249965722673, + "iqr_outliers": 3, + "stddev_outliers": 2, + "outliers": "2;3", + "ld15iqr": 0.00022670819889754058, + "hd15iqr": 0.00023579999979119747, + "ops": 4328.411090272451, + "total": 0.004620633203012403, + "data": [ + 0.00022932499996386468, + 0.00022944999946048484, + 0.0002293333993293345, + 0.00023177499970188363, + 0.00023007499985396861, + 0.0002285334005136974, + 0.0002280665998114273, + 0.0002288500007125549, + 0.00022891660046298057, + 0.00022721660061506555, + 0.00022864999918965624, + 0.00022760840074624865, + 0.00022862500045448542, + 0.00022798340069130063, + 0.00022898340102983639, + 0.00022670819889754058, + 0.00022899160103406757, + 0.00023579999979119747, + 0.00024215000012191014, + 0.00025359160063089805 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/BOP/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/BOP/pandas_ta]", + "params": { + "indicator": "BOP", + "library": "pandas_ta" + }, + "param": "Momentum/BOP/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00035473320021992547, + "max": 0.0003798499994445592, + "mean": 0.00036205205011356154, + "stddev": 7.225315851873669e-06, + "rounds": 20, + "median": 0.00035880000068573277, + "iqr": 7.387699588434738e-06, + "q1": 0.00035757070072577334, + "q3": 0.0003649584003142081, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.00035473320021992547, + "hd15iqr": 0.0003765665998798795, + "ops": 2762.033800627117, + "total": 0.007241041002271231, + "data": [ + 0.0003798499994445592, + 0.00037386659969342875, + 0.00036376680072862654, + 0.00035814159928122536, + 0.0003580332006094977, + 0.0003585415994166397, + 0.0003593916000681929, + 0.00035710820084204896, + 0.00035473320021992547, + 0.00035560819960664956, + 0.0003567084000678733, + 0.0003570082000805996, + 0.00036614999989978967, + 0.0003682500013383105, + 0.0003765665998798795, + 0.0003620999996201135, + 0.0003586500009987503, + 0.0003585668004234321, + 0.0003589500003727153, + 0.00035904999967897313 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/BOP/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/BOP/tulipy]", + "params": { + "indicator": "BOP", + "library": "tulipy" + }, + "param": "Momentum/BOP/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002244666000478901, + "max": 0.00023744179925415664, + "mean": 0.00022864414997457062, + "stddev": 4.089572663123374e-06, + "rounds": 20, + "median": 0.00022708750038873403, + "iqr": 4.887500108452503e-06, + "q1": 0.00022589159998460674, + "q3": 0.00023077910009305924, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0002244666000478901, + "hd15iqr": 0.00023744179925415664, + "ops": 4373.608509604196, + "total": 0.004572882999491412, + "data": [ + 0.00022854999988339842, + 0.00022838320001028478, + 0.00022604999976465477, + 0.00022724160080542788, + 0.00022610839951084927, + 0.0002269333999720402, + 0.0002251915997476317, + 0.00022755839891033247, + 0.00022589160071220248, + 0.00022589159925701097, + 0.00022897500020917504, + 0.00022600820084335284, + 0.00023258319997694344, + 0.00023307500086957588, + 0.00023744179925415664, + 0.0002257334010209888, + 0.0002244666000478901, + 0.0002361999999266118, + 0.00023592499928781763, + 0.00022467499948106707 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PLUS_DI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PLUS_DI/ferro_ta]", + "params": { + "indicator": "PLUS_DI", + "library": "ferro_ta" + }, + "param": "Momentum/PLUS_DI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000775624999369029, + "max": 0.0008040168002480641, + "mean": 0.0007839291703567141, + "stddev": 9.172885970592837e-06, + "rounds": 20, + "median": 0.0007797123995260336, + "iqr": 1.4162499428493967e-05, + "q1": 0.0007775708007102366, + "q3": 0.0007917333001387306, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.000775624999369029, + "hd15iqr": 0.0008040168002480641, + "ops": 1275.6254491014365, + "total": 0.015678583407134284, + "data": [ + 0.0007783749999362044, + 0.0007770166004775092, + 0.0007813418007572182, + 0.0007924250006908551, + 0.0008009500001207925, + 0.0007798000006005168, + 0.0007796331992722116, + 0.0007822584011591971, + 0.0007769750009174459, + 0.0007761750006466172, + 0.0007781250009429641, + 0.0007921331998659298, + 0.0007913334004115314, + 0.0007993584003997967, + 0.0007783416003803723, + 0.0007797915997798555, + 0.0007792084012180567, + 0.000775699999940116, + 0.000775624999369029, + 0.0008040168002480641 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PLUS_DI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PLUS_DI/talib]", + "params": { + "indicator": "PLUS_DI", + "library": "talib" + }, + "param": "Momentum/PLUS_DI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005850750007084571, + "max": 0.0006401332007953897, + "mean": 0.0006037670901423553, + "stddev": 1.682999901580317e-05, + "rounds": 20, + "median": 0.0006078541999158915, + "iqr": 2.4129100347636246e-05, + "q1": 0.0005867542000487447, + "q3": 0.000610883300396381, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0005850750007084571, + "hd15iqr": 0.0006401332007953897, + "ops": 1656.267816392943, + "total": 0.012075341802847106, + "data": [ + 0.0006082250009058043, + 0.0006122250008047559, + 0.0006090499999118037, + 0.000607791799120605, + 0.000609541599988006, + 0.0006084749998990447, + 0.0006171333996462635, + 0.0006401332007953897, + 0.0006348334005451761, + 0.0006225167991942727, + 0.0006079166007111781, + 0.0005887999999686144, + 0.000586183401173912, + 0.0005858415999682621, + 0.0005872166002518497, + 0.0005850750007084571, + 0.0005864915990969166, + 0.0006043834000593051, + 0.0005870084001799114, + 0.000586499999917578 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PLUS_DI/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PLUS_DI/pandas_ta]", + "params": { + "indicator": "PLUS_DI", + "library": "pandas_ta" + }, + "param": "Momentum/PLUS_DI/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.02599250820057932, + "max": 0.02711647499963874, + "mean": 0.026515360819830677, + "stddev": 0.0003152789796676263, + "rounds": 20, + "median": 0.02647980420078966, + "iqr": 0.0005425873998319702, + "q1": 0.026213699999789244, + "q3": 0.026756287399621215, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.02599250820057932, + "hd15iqr": 0.02711647499963874, + "ops": 37.71398800849454, + "total": 0.5303072163966135, + "data": [ + 0.02640910839982098, + 0.02649730839912081, + 0.02674495819956064, + 0.0264833418012131, + 0.02635302499984391, + 0.02711647499963874, + 0.026220625000132714, + 0.026148166799976023, + 0.026139141600287984, + 0.026693091599736363, + 0.02620677499944577, + 0.02599250820057932, + 0.02620020819886122, + 0.026786291800090112, + 0.026767616599681788, + 0.026728016599372496, + 0.026980816600553226, + 0.02692362499947194, + 0.026476266600366217, + 0.026439849998860156 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PLUS_DI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PLUS_DI/tulipy]", + "params": { + "indicator": "PLUS_DI", + "library": "tulipy" + }, + "param": "Momentum/PLUS_DI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006600249995244667, + "max": 0.0008075168007053435, + "mean": 0.0006787658499524695, + "stddev": 3.149416899891165e-05, + "rounds": 20, + "median": 0.0006740750999597367, + "iqr": 1.7104200378525937e-05, + "q1": 0.0006634833000134677, + "q3": 0.0006805875003919936, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0006600249995244667, + "hd15iqr": 0.0008075168007053435, + "ops": 1473.262097776464, + "total": 0.013575316999049392, + "data": [ + 0.0006753084002411924, + 0.0006728417996782809, + 0.000668333399516996, + 0.000687266601016745, + 0.0006717832002323121, + 0.0006819834001362324, + 0.0006627166003454477, + 0.00066474159975769, + 0.0006620665997616015, + 0.0006642499996814877, + 0.0006600249995244667, + 0.0006762168006389402, + 0.0006817000001319684, + 0.0006794750006520189, + 0.0006833084000390955, + 0.0006761333992471918, + 0.0006779749994166196, + 0.0006608749987208285, + 0.000660799999604933, + 0.0008075168007053435 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MINUS_DI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MINUS_DI/ferro_ta]", + "params": { + "indicator": "MINUS_DI", + "library": "ferro_ta" + }, + "param": "Momentum/MINUS_DI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008105999993858859, + "max": 0.00098978340101894, + "mean": 0.0008675133200449636, + "stddev": 4.814281395855114e-05, + "rounds": 20, + "median": 0.0008576666004955769, + "iqr": 6.574580111191615e-05, + "q1": 0.0008262374991318211, + "q3": 0.0008919833002437373, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0008105999993858859, + "hd15iqr": 0.00098978340101894, + "ops": 1152.7200526997897, + "total": 0.01735026640089927, + "data": [ + 0.0008629083997220733, + 0.0008556500004488043, + 0.0008197999995900318, + 0.0008162832004018128, + 0.000856258200656157, + 0.0008257833993411623, + 0.0008549500009394251, + 0.0008669666000059806, + 0.0008176500006811694, + 0.0008105999993858859, + 0.0008741499987081625, + 0.0008913750003557652, + 0.0009344917998532765, + 0.0008590750003349967, + 0.0008986250002635642, + 0.0008925916001317092, + 0.0008438165998086334, + 0.0008266915989224799, + 0.00098978340101894, + 0.0009528166003292427 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MINUS_DI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MINUS_DI/talib]", + "params": { + "indicator": "MINUS_DI", + "library": "talib" + }, + "param": "Momentum/MINUS_DI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005909750005230307, + "max": 0.0007287000000360422, + "mean": 0.0006368779300100868, + "stddev": 4.506923765296199e-05, + "rounds": 20, + "median": 0.000612462499702815, + "iqr": 6.759989919373759e-05, + "q1": 0.0006006042007356882, + "q3": 0.0006682040999294258, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0005909750005230307, + "hd15iqr": 0.0007287000000360422, + "ops": 1570.1596065421234, + "total": 0.012737558600201737, + "data": [ + 0.0006030332006048411, + 0.0007074833993101493, + 0.0006419081997592002, + 0.0006065581997972913, + 0.0007178083993494511, + 0.0006053083998267539, + 0.0005996416002744809, + 0.0006792915999540127, + 0.0006525083997985348, + 0.0005909750005230307, + 0.0006183667996083386, + 0.0006854584004031494, + 0.0006005834002280608, + 0.0006006250012433156, + 0.0006430417997762561, + 0.0005940167990047485, + 0.0006064583998522721, + 0.0007287000000360422, + 0.0006571165999048389, + 0.0005986750009469688 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MINUS_DI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MINUS_DI/tulipy]", + "params": { + "indicator": "MINUS_DI", + "library": "tulipy" + }, + "param": "Momentum/MINUS_DI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005953999992925674, + "max": 0.0007718167995335534, + "mean": 0.0006637608398887096, + "stddev": 5.868423699438252e-05, + "rounds": 20, + "median": 0.0006484666999313049, + "iqr": 9.201240027323365e-05, + "q1": 0.0006140500998299103, + "q3": 0.0007060625001031439, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0005953999992925674, + "hd15iqr": 0.0007718167995335534, + "ops": 1506.566732933004, + "total": 0.013275216797774192, + "data": [ + 0.0007718167995335534, + 0.0006480083990027197, + 0.0006300584005657584, + 0.0007697249995544553, + 0.0007155915998737327, + 0.0006722749996697531, + 0.0006489250008598901, + 0.0006174750000354834, + 0.0006025916009093635, + 0.0006864583992864937, + 0.0006328250005026348, + 0.0006158584001241252, + 0.0006015581995598041, + 0.0007099499998730607, + 0.0006122417995356955, + 0.0006119081997894682, + 0.0005953999992925674, + 0.0006616581988055259, + 0.0007687168006668798, + 0.0007021750003332272 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/ferro_ta]", + "params": { + "indicator": "BBANDS", + "library": "ferro_ta" + }, + "param": "Volatility/BBANDS/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003509333997499198, + "max": 0.0004272334001143463, + "mean": 0.0003724342097848421, + "stddev": 2.004313476173514e-05, + "rounds": 20, + "median": 0.00037039579983684235, + "iqr": 2.4112500250339486e-05, + "q1": 0.00035690840013558045, + "q3": 0.00038102090038591994, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0003509333997499198, + "hd15iqr": 0.0004272334001143463, + "ops": 2685.037984501228, + "total": 0.007448684195696842, + "data": [ + 0.00035908339923480527, + 0.0004003915993962437, + 0.0003714667996973731, + 0.0003611916006775573, + 0.0003509333997499198, + 0.0004021917993668467, + 0.0003710999997565523, + 0.0003553749993443489, + 0.0003539249999448657, + 0.00035714999976335096, + 0.00037394999962998555, + 0.00035666680050780995, + 0.00036110839864704757, + 0.00035137499944539743, + 0.00037489179958356545, + 0.0003696915999171324, + 0.0003750834002858028, + 0.0004272334001143463, + 0.00038695840048603715, + 0.0003889168001478538 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/talib]", + "params": { + "indicator": "BBANDS", + "library": "talib" + }, + "param": "Volatility/BBANDS/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005582500001764856, + "max": 0.0006473249988630414, + "mean": 0.0006026891597866779, + "stddev": 1.965093990752825e-05, + "rounds": 20, + "median": 0.0006067124995752238, + "iqr": 2.283760040882048e-05, + "q1": 0.0005904040997847914, + "q3": 0.0006132417001936119, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0005582500001764856, + "hd15iqr": 0.0006473249988630414, + "ops": 1659.2301085255133, + "total": 0.012053783195733558, + "data": [ + 0.0006110165995778516, + 0.0005986834003124386, + 0.0006230168000911362, + 0.0006473249988630414, + 0.0006073331998777576, + 0.000612350000301376, + 0.0006141334000858478, + 0.0005582500001764856, + 0.0006117331999121234, + 0.0005927165999310091, + 0.0005860249992110766, + 0.0006186749989865348, + 0.0005944749995251186, + 0.0005741916000260971, + 0.0005995500003336928, + 0.0005805249995319173, + 0.000617266799963545, + 0.0006060917992726899, + 0.0005880915996385738, + 0.0006123332001152449 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/pandas_ta]", + "params": { + "indicator": "BBANDS", + "library": "pandas_ta" + }, + "param": "Volatility/BBANDS/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010950084004434757, + "max": 0.0013554749995819293, + "mean": 0.0011527149999892572, + "stddev": 6.16621021602183e-05, + "rounds": 20, + "median": 0.0011341249992256053, + "iqr": 4.970010049873972e-05, + "q1": 0.0011139665999507996, + "q3": 0.0011636667004495393, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0010950084004434757, + "hd15iqr": 0.0013554749995819293, + "ops": 867.5171226272926, + "total": 0.023054299999785144, + "data": [ + 0.0011188331991434097, + 0.001225633401190862, + 0.001233108399901539, + 0.0013554749995819293, + 0.0011493999991216697, + 0.0011455582003691233, + 0.0011688000013236888, + 0.0011471999998320826, + 0.0011072334003983998, + 0.001106066799547989, + 0.00115853339957539, + 0.001121199999761302, + 0.0011268999995081685, + 0.0011239250001381152, + 0.0011127666002721526, + 0.0011030416004359721, + 0.0010950084004434757, + 0.0011991000006673857, + 0.001115166599629447, + 0.001141349998943042 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/ta]", + "params": { + "indicator": "BBANDS", + "library": "ta" + }, + "param": "Volatility/BBANDS/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0021664665997377596, + "max": 0.0024274084003991447, + "mean": 0.0022495016501488862, + "stddev": 7.91569041673182e-05, + "rounds": 20, + "median": 0.002221720899979118, + "iqr": 9.801669948501486e-05, + "q1": 0.002191391600354109, + "q3": 0.0022894082998391237, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0021664665997377596, + "hd15iqr": 0.0024274084003991447, + "ops": 444.5429057292817, + "total": 0.04499003300297773, + "data": [ + 0.002325100000598468, + 0.002275699999881908, + 0.0023031165997963398, + 0.0023731166002107784, + 0.0024274084003991447, + 0.002419683200423606, + 0.0022286084000370464, + 0.0022205250003025866, + 0.002195999999821652, + 0.00221564160019625, + 0.00222291679965565, + 0.0021910581999691203, + 0.0021917250007390974, + 0.0022365249998983925, + 0.0021900332008954137, + 0.0021868499999982303, + 0.0021825584000907837, + 0.0021664665997377596, + 0.002208325000538025, + 0.0022286749997874724 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/tulipy]", + "params": { + "indicator": "BBANDS", + "library": "tulipy" + }, + "param": "Volatility/BBANDS/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003947331992094405, + "max": 0.00043956659937975927, + "mean": 0.00040887208000640386, + "stddev": 1.063309060756816e-05, + "rounds": 20, + "median": 0.00040803749943734146, + "iqr": 1.0020799527410385e-05, + "q1": 0.0004019292005978059, + "q3": 0.0004119500001252163, + "iqr_outliers": 1, + "stddev_outliers": 7, + "outliers": "7;1", + "ld15iqr": 0.0003947331992094405, + "hd15iqr": 0.00043956659937975927, + "ops": 2445.7527155787643, + "total": 0.008177441600128076, + "data": [ + 0.0003978250009822659, + 0.0003974249993916601, + 0.0004060334002133459, + 0.0004103915998712182, + 0.0004133081994950771, + 0.0003954915999202058, + 0.00043956659937975927, + 0.0004114249997655861, + 0.0004079749996890314, + 0.0004105333995539695, + 0.0004124750004848465, + 0.00040731660119490697, + 0.0004074584008776583, + 0.0003947331992094405, + 0.00042448339954717087, + 0.00040786659956211225, + 0.0004080999991856515, + 0.0003957499997341074, + 0.00041061680094571783, + 0.0004186668011243455 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/finta]", + "params": { + "indicator": "BBANDS", + "library": "finta" + }, + "param": "Volatility/BBANDS/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002377016798709519, + "max": 0.0027651833996060306, + "mean": 0.0024591325299843448, + "stddev": 0.00010189434715016175, + "rounds": 20, + "median": 0.00242138760004309, + "iqr": 7.110000078682736e-05, + "q1": 0.0023949249996803703, + "q3": 0.0024660250004671976, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.002377016798709519, + "hd15iqr": 0.0026048584011732602, + "ops": 406.64746117053164, + "total": 0.04918265059968689, + "data": [ + 0.002414566800871398, + 0.0026503749992116354, + 0.002514399999927264, + 0.002428783399227541, + 0.002461591600149404, + 0.0024163918002159334, + 0.002401316798932385, + 0.0024263833998702466, + 0.0024704584007849916, + 0.002377016798709519, + 0.0024158583997632376, + 0.0023885332004283553, + 0.0023875500002759507, + 0.0024080999995931053, + 0.002385208400664851, + 0.0027651833996060306, + 0.0026048584011732602, + 0.002428958199743647, + 0.002453599999716971, + 0.002383516600821167 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/ferro_ta]", + "params": { + "indicator": "ATR", + "library": "ferro_ta" + }, + "param": "Volatility/ATR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006273081991821528, + "max": 0.0006722667996655219, + "mean": 0.0006353250001120614, + "stddev": 1.1564092958224678e-05, + "rounds": 20, + "median": 0.0006301957997493445, + "iqr": 1.1220799933653325e-05, + "q1": 0.0006283375005295966, + "q3": 0.0006395583004632499, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0006273081991821528, + "hd15iqr": 0.0006722667996655219, + "ops": 1573.997560026153, + "total": 0.012706500002241227, + "data": [ + 0.0006313499994575978, + 0.0006302415989921428, + 0.0006300500012002885, + 0.0006301500005065463, + 0.0006296084000496193, + 0.0006287416006671264, + 0.0006274583996855654, + 0.0006317250008578412, + 0.0006519415997900069, + 0.0006722667996655219, + 0.0006518334004795179, + 0.0006395083997631446, + 0.0006275666004512459, + 0.0006273081991821528, + 0.0006305250004515983, + 0.0006275749998167157, + 0.0006279334003920667, + 0.0006396082011633552, + 0.0006419665995053947, + 0.0006291418001637794 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/talib]", + "params": { + "indicator": "ATR", + "library": "talib" + }, + "param": "Volatility/ATR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006467084007454105, + "max": 0.0006665749999228865, + "mean": 0.0006528558399440953, + "stddev": 5.429730122700573e-06, + "rounds": 20, + "median": 0.0006508625003334601, + "iqr": 7.88339966675258e-06, + "q1": 0.000648716699652141, + "q3": 0.0006566000993188936, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0006467084007454105, + "hd15iqr": 0.0006665749999228865, + "ops": 1531.7317220990637, + "total": 0.013057116798881907, + "data": [ + 0.0006529916005092673, + 0.0006497834008769132, + 0.0006513415995868854, + 0.0006560833993717097, + 0.0006665749999228865, + 0.0006599250002182089, + 0.0006579084001714364, + 0.0006482749988208525, + 0.0006494083994766697, + 0.0006467084007454105, + 0.0006508250007755123, + 0.0006486750004114583, + 0.0006477582006482408, + 0.0006571167992660776, + 0.0006615749996853992, + 0.0006508999998914078, + 0.0006487583988928237, + 0.0006474832000094466, + 0.0006549749989062548, + 0.000650050000695046 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/pandas_ta]", + "params": { + "indicator": "ATR", + "library": "pandas_ta" + }, + "param": "Volatility/ATR/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007892918001743965, + "max": 0.0008510750005370938, + "mean": 0.0008032075201481348, + "stddev": 1.691282437864719e-05, + "rounds": 20, + "median": 0.0007948541999212467, + "iqr": 1.1566699686227323e-05, + "q1": 0.0007941666000988335, + "q3": 0.0008057332997850608, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0007892918001743965, + "hd15iqr": 0.00084177500102669, + "ops": 1245.0082636372365, + "total": 0.016064150402962697, + "data": [ + 0.0008220668009016663, + 0.0008089249997283332, + 0.0007948665996082127, + 0.0007946833997266367, + 0.0007935583998914808, + 0.000794033199781552, + 0.0007933418004540726, + 0.0008025415998417884, + 0.0008142917999066412, + 0.0007946915997308679, + 0.0007948418002342805, + 0.000790650000271853, + 0.0007970500009832904, + 0.0007892918001743965, + 0.0007943000004161149, + 0.0007997331995284185, + 0.0008510750005370938, + 0.00084177500102669, + 0.0007980416005011648, + 0.0007943917997181415 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/ta]", + "params": { + "indicator": "ATR", + "library": "ta" + }, + "param": "Volatility/ATR/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.15432768339960604, + "max": 0.16315043340000557, + "mean": 0.1580097324601229, + "stddev": 0.0024508512755115714, + "rounds": 20, + "median": 0.15749782909988425, + "iqr": 0.004141237500152772, + "q1": 0.1558303748999606, + "q3": 0.15997161240011337, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.15432768339960604, + "hd15iqr": 0.16315043340000557, + "ops": 6.328724088260646, + "total": 3.160194649202458, + "data": [ + 0.15803537500032688, + 0.15750929999921937, + 0.16039933340071003, + 0.1596118415996898, + 0.15536931660026312, + 0.15484074160049205, + 0.15502613319986266, + 0.1558452415993088, + 0.1591044166008942, + 0.16042422500031533, + 0.15581550820061238, + 0.1571958832006203, + 0.15748635820054915, + 0.16033138320053694, + 0.15937074160028714, + 0.16174541679938556, + 0.15716737500042655, + 0.15432768339960604, + 0.16315043340000557, + 0.15743794159934624 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/tulipy]", + "params": { + "indicator": "ATR", + "library": "tulipy" + }, + "param": "Volatility/ATR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00035946680000051854, + "max": 0.00037548319960478693, + "mean": 0.0003645900000992697, + "stddev": 5.0215106339514e-06, + "rounds": 20, + "median": 0.00036251249985070895, + "iqr": 7.191499025793703e-06, + "q1": 0.00036071260110475127, + "q3": 0.000367904100130545, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.00035946680000051854, + "hd15iqr": 0.00037548319960478693, + "ops": 2742.806987925402, + "total": 0.007291800001985394, + "data": [ + 0.0003662834002170712, + 0.00036117499985266477, + 0.0003687832009745762, + 0.0003631415995187126, + 0.000360791600542143, + 0.00036056679964531214, + 0.00035987500014016404, + 0.00036702499928651375, + 0.00036331660085124894, + 0.00037109159893589094, + 0.0003718250009114854, + 0.00036109159991610796, + 0.00037548319960478693, + 0.0003731083997990936, + 0.00036188340018270535, + 0.00035946680000051854, + 0.0003655499996966682, + 0.0003606584010412917, + 0.00035991659970022736, + 0.0003607668011682108 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/finta]", + "params": { + "indicator": "ATR", + "library": "finta" + }, + "param": "Volatility/ATR/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.006690908399468754, + "max": 0.007669633199111558, + "mean": 0.0069351595700572945, + "stddev": 0.00022864816613486384, + "rounds": 20, + "median": 0.006886704199860105, + "iqr": 0.0001395292005327061, + "q1": 0.00679940420013736, + "q3": 0.006938933400670066, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.006690908399468754, + "hd15iqr": 0.007379100000252947, + "ops": 144.19278891830004, + "total": 0.1387031914011459, + "data": [ + 0.007669633199111558, + 0.007379100000252947, + 0.007139191600435879, + 0.006940716800454538, + 0.0068578999998862855, + 0.006931508400884923, + 0.006933166598901153, + 0.00681439159961883, + 0.006781408200913575, + 0.006794900000386406, + 0.006690908399468754, + 0.006860908400267362, + 0.006752366600267123, + 0.0068236249993788075, + 0.006921800000418444, + 0.006912499999452848, + 0.006937150000885595, + 0.006803908399888314, + 0.0067896166001446545, + 0.00696849160012789 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/NATR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/NATR/ferro_ta]", + "params": { + "indicator": "NATR", + "library": "ferro_ta" + }, + "param": "Volatility/NATR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007020334000117145, + "max": 0.0007296499999938533, + "mean": 0.0007097550001344643, + "stddev": 7.67445096453596e-06, + "rounds": 20, + "median": 0.0007064582998282277, + "iqr": 1.2620801135199166e-05, + "q1": 0.000703599999542348, + "q3": 0.0007162208006775472, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0007020334000117145, + "hd15iqr": 0.0007296499999938533, + "ops": 1408.936886405236, + "total": 0.014195100002689287, + "data": [ + 0.0007107418001396582, + 0.0007296499999938533, + 0.0007159750006394461, + 0.0007111250000889413, + 0.0007063500001095235, + 0.000702350000210572, + 0.0007035165996057913, + 0.000703883399546612, + 0.0007020750010269694, + 0.0007147917989641428, + 0.0007172750003519468, + 0.0007195666010375134, + 0.0007065665995469317, + 0.0007020334000117145, + 0.0007047584003885277, + 0.0007020416000159457, + 0.0007036833994789049, + 0.0007054332003463059, + 0.0007164666007156484, + 0.0007168166004703381 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/NATR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/NATR/talib]", + "params": { + "indicator": "NATR", + "library": "talib" + }, + "param": "Volatility/NATR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006496583999251015, + "max": 0.0006677249999484048, + "mean": 0.000654559559916379, + "stddev": 6.038357473321768e-06, + "rounds": 20, + "median": 0.0006515291999676265, + "iqr": 7.875100709497885e-06, + "q1": 0.000650412399409106, + "q3": 0.0006582875001186039, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0006496583999251015, + "hd15iqr": 0.0006677249999484048, + "ops": 1527.744855071327, + "total": 0.013091191198327579, + "data": [ + 0.000650741599383764, + 0.0006519500006106682, + 0.0006508915990707465, + 0.0006510416002129205, + 0.0006518334004795179, + 0.000666516600176692, + 0.0006602666006074287, + 0.0006677249999484048, + 0.0006523083997308276, + 0.0006500165996840224, + 0.0006498831993667409, + 0.0006508999998914078, + 0.0006496583999251015, + 0.0006539834008435719, + 0.0006576081999810412, + 0.0006656415993347764, + 0.0006589668002561667, + 0.0006499499999335967, + 0.000650083199434448, + 0.0006512249994557351 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/NATR/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/NATR/pandas_ta]", + "params": { + "indicator": "NATR", + "library": "pandas_ta" + }, + "param": "Volatility/NATR/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007635834001121112, + "max": 0.0008178249991033226, + "mean": 0.0007758645799185615, + "stddev": 1.46322804257248e-05, + "rounds": 20, + "median": 0.0007686375000048428, + "iqr": 1.934159881784587e-05, + "q1": 0.0007660333008971065, + "q3": 0.0007853748997149524, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0007635834001121112, + "hd15iqr": 0.0008178249991033226, + "ops": 1288.884717620393, + "total": 0.01551729159837123, + "data": [ + 0.0007687083998462185, + 0.0007950250001158565, + 0.0008178249991033226, + 0.0007849831992643886, + 0.0007733167993137613, + 0.0007646833997569047, + 0.0007635834001121112, + 0.0007640832001925447, + 0.0007685666001634673, + 0.0007890749999205582, + 0.0007776500002364628, + 0.000766075000865385, + 0.0007659916009288281, + 0.0007673915999475867, + 0.0007669833998079411, + 0.0007672249994357117, + 0.0007636084003024735, + 0.0007857666001655162, + 0.0007970499995280988, + 0.000769699999364093 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/NATR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/NATR/tulipy]", + "params": { + "indicator": "NATR", + "library": "tulipy" + }, + "param": "Volatility/NATR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003569334003259428, + "max": 0.00037218320067040623, + "mean": 0.00036119998032518196, + "stddev": 4.274058487789185e-06, + "rounds": 20, + "median": 0.00035986660077469423, + "iqr": 4.937499761581399e-06, + "q1": 0.0003581666998798028, + "q3": 0.0003631041996413842, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0003569334003259428, + "hd15iqr": 0.00037218320067040623, + "ops": 2768.5494309820215, + "total": 0.00722399960650364, + "data": [ + 0.00036006660084240136, + 0.0003610582003602758, + 0.0003634084001532756, + 0.000358083400351461, + 0.0003596666007069871, + 0.00036045000015292315, + 0.0003572000001440756, + 0.0003627999991294928, + 0.0003669167999760248, + 0.00037218320067040623, + 0.0003644666008767672, + 0.0003586083999834955, + 0.00035817500029224905, + 0.0003569334003259428, + 0.0003581583994673565, + 0.00035825000086333605, + 0.0003593416011426598, + 0.00036108320055063816, + 0.0003698582004290074, + 0.0003572916000848636 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/ferro_ta]", + "params": { + "indicator": "TRANGE", + "library": "ferro_ta" + }, + "param": "Volatility/TRANGE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000199366599554196, + "max": 0.00021681660000467674, + "mean": 0.00020486998990236317, + "stddev": 4.843823795716784e-06, + "rounds": 20, + "median": 0.0002023707995249424, + "iqr": 7.545799599029129e-06, + "q1": 0.00020136669991188683, + "q3": 0.00020891249951091596, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.000199366599554196, + "hd15iqr": 0.00021681660000467674, + "ops": 4881.144380768405, + "total": 0.0040973997980472635, + "data": [ + 0.00020106680021854119, + 0.00020243339968146757, + 0.00020138340041739867, + 0.000199366599554196, + 0.0002067331995931454, + 0.00020962499984307216, + 0.00020819999917875975, + 0.00021308339928509666, + 0.0002033334007137455, + 0.00020989159966120497, + 0.0002051916002528742, + 0.00021681660000467674, + 0.00020989159966120497, + 0.00020134999940637499, + 0.00020225000043865292, + 0.00020145839953329415, + 0.00020230819936841726, + 0.00020145840098848567, + 0.0002010331998462789, + 0.00020052500040037557 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/talib]", + "params": { + "indicator": "TRANGE", + "library": "talib" + }, + "param": "Volatility/TRANGE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001995915998122655, + "max": 0.0002186834011808969, + "mean": 0.0002054641500581056, + "stddev": 4.6589727700061155e-06, + "rounds": 20, + "median": 0.000204404099349631, + "iqr": 3.920900780940428e-06, + "q1": 0.0002027166003244929, + "q3": 0.00020663750110543332, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0001995915998122655, + "hd15iqr": 0.00021624179935315625, + "ops": 4867.029112948407, + "total": 0.004109283001162112, + "data": [ + 0.00020444999972824007, + 0.00020383340015541762, + 0.00020652499952120707, + 0.0002069665992166847, + 0.00020312500128056855, + 0.00020539160032058136, + 0.00021624179935315625, + 0.0002067000008537434, + 0.00020170839998172595, + 0.00020230819936841726, + 0.0001995915998122655, + 0.000203575000341516, + 0.00020199999999022112, + 0.0002186834011808969, + 0.00020657500135712326, + 0.00020800000056624411, + 0.00020435819897102192, + 0.00020484159904299304, + 0.00020338320027804002, + 0.0002010249998420477 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/pandas_ta]", + "params": { + "indicator": "TRANGE", + "library": "pandas_ta" + }, + "param": "Volatility/TRANGE/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003453582001384348, + "max": 0.000493208400439471, + "mean": 0.0003782029400463216, + "stddev": 4.097742184394216e-05, + "rounds": 20, + "median": 0.0003604208999604452, + "iqr": 1.6591599705861892e-05, + "q1": 0.00035568340026657095, + "q3": 0.00037227499997243284, + "iqr_outliers": 4, + "stddev_outliers": 3, + "outliers": "3;4", + "ld15iqr": 0.0003453582001384348, + "hd15iqr": 0.00040044159977696835, + "ops": 2644.083094323703, + "total": 0.007564058800926432, + "data": [ + 0.0003727916002389975, + 0.0004435668000951409, + 0.000493208400439471, + 0.000465266800893005, + 0.00036865000001853334, + 0.00035844160011038183, + 0.00035818339965771885, + 0.0003547833999618888, + 0.00036010839976370336, + 0.00036073340015718713, + 0.0003586999999242835, + 0.00036902499996358527, + 0.0003717583997058682, + 0.00035354999999981374, + 0.000347933400189504, + 0.00035658340057125314, + 0.0003453582001384348, + 0.00040044159977696835, + 0.0003716331993928179, + 0.0003533417999278754 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/tulipy]", + "params": { + "indicator": "TRANGE", + "library": "tulipy" + }, + "param": "Volatility/TRANGE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019571659940993414, + "max": 0.00020935000065946952, + "mean": 0.000199859580170596, + "stddev": 3.5775942323553067e-06, + "rounds": 20, + "median": 0.00019950409987359307, + "iqr": 4.383400664664805e-06, + "q1": 0.00019697499956237153, + "q3": 0.00020135840022703633, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.00019571659940993414, + "hd15iqr": 0.00020935000065946952, + "ops": 5003.512962182852, + "total": 0.00399719160341192, + "data": [ + 0.00020090000034542755, + 0.00019794179970631376, + 0.00020054159977007658, + 0.00020026660058647394, + 0.00020070820028195158, + 0.00019891660049324854, + 0.00019680840050568805, + 0.00019770840008277447, + 0.00020235820120433344, + 0.00020608339982572944, + 0.0002040500010480173, + 0.00020935000065946952, + 0.00020181680010864512, + 0.0002000915992539376, + 0.00019614160119090228, + 0.00019627499859780072, + 0.00019571659940993414, + 0.0001975668012164533, + 0.00019707499886862935, + 0.0001968750002561137 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/finta]", + "params": { + "indicator": "TRANGE", + "library": "finta" + }, + "param": "Volatility/TRANGE/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.006501883199962322, + "max": 0.0073248749991762455, + "mean": 0.006907176669847104, + "stddev": 0.00023645787493183987, + "rounds": 20, + "median": 0.006930641700455454, + "iqr": 0.00032172920036828076, + "q1": 0.006748254099511542, + "q3": 0.0070699832998798225, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.006501883199962322, + "hd15iqr": 0.0073248749991762455, + "ops": 144.77695414472956, + "total": 0.13814353339694208, + "data": [ + 0.007002500000817235, + 0.006790883399662562, + 0.007236674999876414, + 0.006558133399812505, + 0.0073248749991762455, + 0.007149550000031013, + 0.00706919999938691, + 0.00675068319978891, + 0.006745824999234174, + 0.007031566600198857, + 0.007070766600372735, + 0.0071403250010916965, + 0.007035483399522491, + 0.006501883199962322, + 0.006858783400093671, + 0.006686166799045168, + 0.006790066599205602, + 0.00676474159990903, + 0.007063491799635812, + 0.006571933400118723 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/ferro_ta]", + "params": { + "indicator": "STDDEV", + "library": "ferro_ta" + }, + "param": "Volatility/STDDEV/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005841249992954544, + "max": 0.0006848833989351987, + "mean": 0.0006335545798356179, + "stddev": 3.0441235815611137e-05, + "rounds": 20, + "median": 0.0006240541006263811, + "iqr": 5.135009996592988e-05, + "q1": 0.0006076498997572345, + "q3": 0.0006589999997231643, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0005841249992954544, + "hd15iqr": 0.0006848833989351987, + "ops": 1578.395976964542, + "total": 0.012671091596712359, + "data": [ + 0.000599016599880997, + 0.0006498250004369766, + 0.0006164749996969476, + 0.0006495167996035889, + 0.000621958400006406, + 0.0006164082005852833, + 0.0006224832002772018, + 0.0006737999996403232, + 0.0006365084002027289, + 0.0006825833988841623, + 0.0006848833989351987, + 0.0006517999994684942, + 0.0006661999999778345, + 0.0006038915991666727, + 0.0006256250009755604, + 0.0005841249992954544, + 0.000603074999526143, + 0.0006687000000965782, + 0.0006028083997080102, + 0.0006114082003477961 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/talib]", + "params": { + "indicator": "STDDEV", + "library": "talib" + }, + "param": "Volatility/STDDEV/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003510249996907078, + "max": 0.00043189179996261373, + "mean": 0.0003639195698633557, + "stddev": 1.749296585166202e-05, + "rounds": 20, + "median": 0.00036036659948877057, + "iqr": 1.1458299559308238e-05, + "q1": 0.0003544917002727743, + "q3": 0.00036594999983208254, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0003510249996907078, + "hd15iqr": 0.00043189179996261373, + "ops": 2747.8599196396044, + "total": 0.0072783913972671145, + "data": [ + 0.00035420000058365985, + 0.0003652250001323409, + 0.0003528749992256053, + 0.00035510000016074627, + 0.0003628749997005798, + 0.00035749160015257074, + 0.00036612499970942734, + 0.00036577499995473774, + 0.00036189160018693654, + 0.00036762499948963525, + 0.00035395000013522805, + 0.0003588415987906046, + 0.000356583199754823, + 0.0003684750001411885, + 0.0003510249996907078, + 0.0003629749990068376, + 0.000379033200442791, + 0.00043189179996261373, + 0.0003547833999618888, + 0.00035165000008419156 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/pandas_ta]", + "params": { + "indicator": "STDDEV", + "library": "pandas_ta" + }, + "param": "Volatility/STDDEV/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00041963320109061897, + "max": 0.0004996584000764414, + "mean": 0.0004415583202353446, + "stddev": 2.3531203734759713e-05, + "rounds": 20, + "median": 0.0004316624996135943, + "iqr": 1.9866700313286856e-05, + "q1": 0.0004270333003660198, + "q3": 0.0004469000006793067, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.00041963320109061897, + "hd15iqr": 0.0004834250008570962, + "ops": 2264.706504606263, + "total": 0.008831166404706891, + "data": [ + 0.0004834250008570962, + 0.0004474000001209788, + 0.00043000000005122274, + 0.0004937000005156734, + 0.00045078340044710783, + 0.000436700000136625, + 0.0004361415994935669, + 0.0004264083996531554, + 0.0004299831998650916, + 0.00042597499996190893, + 0.00042761660006362945, + 0.00041963320109061897, + 0.0004312999997637235, + 0.0004264500006684102, + 0.00043202499946346504, + 0.00042770000000018625, + 0.00043931660038651896, + 0.00042055000085383654, + 0.0004996584000764414, + 0.00044640000123763457 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/tulipy]", + "params": { + "indicator": "STDDEV", + "library": "tulipy" + }, + "param": "Volatility/STDDEV/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00039208340022014456, + "max": 0.0005356416004360653, + "mean": 0.000417036679937155, + "stddev": 3.938035639001007e-05, + "rounds": 20, + "median": 0.0004008749994682148, + "iqr": 1.4408300194190783e-05, + "q1": 0.00039727089970256204, + "q3": 0.0004116791998967528, + "iqr_outliers": 4, + "stddev_outliers": 2, + "outliers": "2;4", + "ld15iqr": 0.00039208340022014456, + "hd15iqr": 0.00043356660025892777, + "ops": 2397.870614524109, + "total": 0.0083407335987431, + "data": [ + 0.0005172917997697368, + 0.0005356416004360653, + 0.0004361916013294831, + 0.00039707499963697045, + 0.00040743340068729597, + 0.00040913339908001947, + 0.00041160840046359224, + 0.00039937499968800694, + 0.0004117499993299134, + 0.00040967499953694644, + 0.00043356660025892777, + 0.00039870000036899, + 0.00039802499959478154, + 0.0003957417997298762, + 0.0003943415998946875, + 0.0004007583993370645, + 0.0003974667997681536, + 0.0004009915995993651, + 0.00039388320001307873, + 0.00039208340022014456 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/finta]", + "params": { + "indicator": "STDDEV", + "library": "finta" + }, + "param": "Volatility/STDDEV/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0015132583997910843, + "max": 0.0017106165993027388, + "mean": 0.0015450800000689924, + "stddev": 4.431755689699057e-05, + "rounds": 20, + "median": 0.0015321667007810902, + "iqr": 2.820009976858286e-05, + "q1": 0.0015199624001979827, + "q3": 0.0015481624999665656, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0015132583997910843, + "hd15iqr": 0.0017106165993027388, + "ops": 647.2156781236874, + "total": 0.03090160000137985, + "data": [ + 0.0017106165993027388, + 0.0015636084004654548, + 0.0015471499995328487, + 0.0015243416011799127, + 0.0015837418002774939, + 0.0015866000001551583, + 0.0015332918002968654, + 0.001531041601265315, + 0.0015164083990384825, + 0.0015377168005215936, + 0.0015437000009114854, + 0.001521208199847024, + 0.0015132583997910843, + 0.00153428339981474, + 0.0015187166005489416, + 0.0015168249999987892, + 0.0015226249990519137, + 0.0015296331999707035, + 0.0015491750004002825, + 0.0015176581990090198 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/VAR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/VAR/ferro_ta]", + "params": { + "indicator": "VAR", + "library": "ferro_ta" + }, + "param": "Volatility/VAR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012193250004202127, + "max": 0.0012741084006847813, + "mean": 0.0012327412699960406, + "stddev": 1.4236889073907756e-05, + "rounds": 20, + "median": 0.001228416699450463, + "iqr": 2.129149870597766e-05, + "q1": 0.0012217376002809032, + "q3": 0.0012430290989868809, + "iqr_outliers": 0, + "stddev_outliers": 2, + "outliers": "2;0", + "ld15iqr": 0.0012193250004202127, + "hd15iqr": 0.0012741084006847813, + "ops": 811.2002285793611, + "total": 0.02465482539992081, + "data": [ + 0.0012741084006847813, + 0.0012509499996667729, + 0.0012232417997438461, + 0.0012290749989915639, + 0.0012315584011957982, + 0.0012438415986252948, + 0.0012400666004396045, + 0.0012224750011228026, + 0.0012201999998069368, + 0.0012296749991946854, + 0.0012457083998015151, + 0.0012277583999093622, + 0.0012243582008522936, + 0.001220516799367033, + 0.0012193250004202127, + 0.0012422165993484669, + 0.0012459584002499468, + 0.0012222667995956727, + 0.0012203165999380872, + 0.0012212084009661339 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/VAR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/VAR/talib]", + "params": { + "indicator": "VAR", + "library": "talib" + }, + "param": "Volatility/VAR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003168331997585483, + "max": 0.0003314415997010656, + "mean": 0.00032112122986291067, + "stddev": 4.052501062634099e-06, + "rounds": 20, + "median": 0.0003200624996679835, + "iqr": 3.183401713613464e-06, + "q1": 0.0003187457994499709, + "q3": 0.00032192920116358437, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.0003168331997585483, + "hd15iqr": 0.0003309915991849266, + "ops": 3114.088721031955, + "total": 0.006422424597258214, + "data": [ + 0.0003221083999960683, + 0.00032179180125240235, + 0.00032007499976316467, + 0.0003200917999492958, + 0.00032004999957280234, + 0.00032127500016940755, + 0.0003204917986295186, + 0.0003182665997883305, + 0.0003178915998432785, + 0.0003187915994203649, + 0.00031869999947957693, + 0.00031893340055830775, + 0.0003195416007656604, + 0.0003168331997585483, + 0.00031881659961072726, + 0.00031770819914527236, + 0.00032655819959472867, + 0.0003309915991849266, + 0.0003314415997010656, + 0.0003220666010747664 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/VAR/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/VAR/pandas_ta]", + "params": { + "indicator": "VAR", + "library": "pandas_ta" + }, + "param": "Volatility/VAR/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003898665992892347, + "max": 0.00042436680087121204, + "mean": 0.0003993503902165685, + "stddev": 1.0280732457295205e-05, + "rounds": 20, + "median": 0.0003947458004404325, + "iqr": 9.479200525674958e-06, + "q1": 0.00039287910040002315, + "q3": 0.0004023583009256981, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0003898665992892347, + "hd15iqr": 0.0004215666005620733, + "ops": 2504.066665510701, + "total": 0.00798700780433137, + "data": [ + 0.0004017166007542983, + 0.0003958250003051944, + 0.0003934416003176011, + 0.00039346659905277194, + 0.00039916660025482995, + 0.00039110840007197114, + 0.0003936666005756706, + 0.0003898665992892347, + 0.00039194159908220174, + 0.0003951750011765398, + 0.000403000001097098, + 0.00042436680087121204, + 0.0004215666005620733, + 0.0004157666000537574, + 0.00040855820116121323, + 0.0003943165997043252, + 0.00039576679992023853, + 0.00039187499933177603, + 0.0003923166004824452, + 0.00039410000026691706 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/VAR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/VAR/tulipy]", + "params": { + "indicator": "VAR", + "library": "tulipy" + }, + "param": "Volatility/VAR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003840334000415169, + "max": 0.0004060499995830469, + "mean": 0.00039079582995327656, + "stddev": 6.18639668412937e-06, + "rounds": 20, + "median": 0.00038919170037843287, + "iqr": 8.712500130059241e-06, + "q1": 0.00038629580012639053, + "q3": 0.00039500830025644977, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0003840334000415169, + "hd15iqr": 0.0004060499995830469, + "ops": 2558.880938211546, + "total": 0.00781591659906553, + "data": [ + 0.0003867916006129235, + 0.0003857999996398576, + 0.00039258340111700816, + 0.00039789159927750006, + 0.0004060499995830469, + 0.00038987500010989606, + 0.00039834999915910885, + 0.0003968334000092, + 0.0003878581992466934, + 0.0003931832005036995, + 0.00038679179997416215, + 0.0003904334007529542, + 0.0003840334000415169, + 0.0003893500004778616, + 0.0003875581998727284, + 0.00038427499966928733, + 0.000384624999423977, + 0.0003842831996735185, + 0.0003890334002790041, + 0.00040031679964158684 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SAR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SAR/ferro_ta]", + "params": { + "indicator": "SAR", + "library": "ferro_ta" + }, + "param": "Volatility/SAR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00046005000040167944, + "max": 0.00047868340043351055, + "mean": 0.0004667787501239218, + "stddev": 5.56152546586572e-06, + "rounds": 20, + "median": 0.0004655374999856576, + "iqr": 6.366799061652295e-06, + "q1": 0.0004623375010851305, + "q3": 0.00046870430014678277, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.00046005000040167944, + "hd15iqr": 0.00047868340043351055, + "ops": 2142.3425975036716, + "total": 0.009335575002478436, + "data": [ + 0.00046916680003050715, + 0.0004682418002630584, + 0.0004681581995100714, + 0.0004694000002928078, + 0.0004646417990443297, + 0.00046489160013152286, + 0.00046242500102380293, + 0.0004675500007579103, + 0.00046148340043146164, + 0.00047684160090284423, + 0.0004779499999131076, + 0.000460999998904299, + 0.00046374999947147445, + 0.00047868340043351055, + 0.0004651499999454245, + 0.0004608082002960145, + 0.00046005000040167944, + 0.00046720819955226034, + 0.00046592500002589076, + 0.0004622500011464581 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SAR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SAR/talib]", + "params": { + "indicator": "SAR", + "library": "talib" + }, + "param": "Volatility/SAR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00045893339993199336, + "max": 0.0006141831996501424, + "mean": 0.00047809244970267175, + "stddev": 3.4906825137420184e-05, + "rounds": 20, + "median": 0.00046863749957992695, + "iqr": 1.14542999654077e-05, + "q1": 0.0004622956999810413, + "q3": 0.000473749999946449, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.00045893339993199336, + "hd15iqr": 0.0005187749993638135, + "ops": 2091.6456652304496, + "total": 0.009561848994053435, + "data": [ + 0.0004903915993054398, + 0.00047010840062284843, + 0.0004639831997337751, + 0.0004681249993154779, + 0.0004629332004697062, + 0.00046117499878164383, + 0.00046305819996632635, + 0.0004616581994923763, + 0.0004604831992764957, + 0.00045893339993199336, + 0.0004841165995458141, + 0.0004602834000252187, + 0.00046320819965330886, + 0.00047279160062316803, + 0.000469149999844376, + 0.00047106659912969916, + 0.0005187749993638135, + 0.0006141831996501424, + 0.000472716600052081, + 0.00047470839926972986 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SAR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SAR/tulipy]", + "params": { + "indicator": "SAR", + "library": "tulipy" + }, + "param": "Volatility/SAR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00043814179953187703, + "max": 0.0004706666004494764, + "mean": 0.00044794749017455614, + "stddev": 9.076453719409353e-06, + "rounds": 20, + "median": 0.0004435834009200335, + "iqr": 1.5037599951028824e-05, + "q1": 0.0004403833001560997, + "q3": 0.00045542090010712853, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00043814179953187703, + "hd15iqr": 0.0004706666004494764, + "ops": 2232.4045160077135, + "total": 0.008958949803491123, + "data": [ + 0.00045022500125924125, + 0.0004442418008693494, + 0.00044666659960057586, + 0.00045816659985575825, + 0.0004706666004494764, + 0.00045872500049881637, + 0.00045927500032121315, + 0.00045310840068850665, + 0.0004403666011057794, + 0.00044003319926559924, + 0.00043983340001432227, + 0.0004416081996168941, + 0.00043814179953187703, + 0.0004423584003234282, + 0.0004400416000862606, + 0.0004418331998749636, + 0.00044292500097071753, + 0.0004526000004261732, + 0.0004577333995257504, + 0.00044039999920642003 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/KELTNER_CHANNELS/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/KELTNER_CHANNELS/ferro_ta]", + "params": { + "indicator": "KELTNER_CHANNELS", + "library": "ferro_ta" + }, + "param": "Volatility/KELTNER_CHANNELS/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0009138918001553975, + "max": 0.0019029249990126118, + "mean": 0.0010568024999520276, + "stddev": 0.0002039148879070644, + "rounds": 20, + "median": 0.0010171833004278597, + "iqr": 5.736670063924963e-05, + "q1": 0.000984566699480638, + "q3": 0.0010419334001198876, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0009138918001553975, + "hd15iqr": 0.0019029249990126118, + "ops": 946.2506003206786, + "total": 0.021136049999040552, + "data": [ + 0.0009138918001553975, + 0.0010429500005557201, + 0.0019029249990126118, + 0.0010791166001581586, + 0.0009910834007314407, + 0.001016566601174418, + 0.0009834333992330357, + 0.0009916750001139007, + 0.0010199165990343317, + 0.0010160499994526617, + 0.0010177999996813015, + 0.0011063416008255445, + 0.0010313833990949206, + 0.001025191599910613, + 0.0009644081990700215, + 0.001040916799684055, + 0.0010654500001692213, + 0.0009856999997282401, + 0.0009667083999374881, + 0.000974541601317469 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/KELTNER_CHANNELS/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/KELTNER_CHANNELS/pandas_ta]", + "params": { + "indicator": "KELTNER_CHANNELS", + "library": "pandas_ta" + }, + "param": "Volatility/KELTNER_CHANNELS/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010600499997963197, + "max": 0.0011597499993513337, + "mean": 0.0010898254199855728, + "stddev": 3.014568783125674e-05, + "rounds": 20, + "median": 0.0010822374999406748, + "iqr": 3.91583002055996e-05, + "q1": 0.001065566699980991, + "q3": 0.0011047250001865905, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0010600499997963197, + "hd15iqr": 0.0011597499993513337, + "ops": 917.5781567044362, + "total": 0.021796508399711458, + "data": [ + 0.0011390250001568347, + 0.0010625750001054257, + 0.0010896916006458922, + 0.0011597499993513337, + 0.001118549999955576, + 0.0010831749998033047, + 0.0010822083990206012, + 0.0010776084003737197, + 0.0010732333990745246, + 0.001090900000417605, + 0.0010822666008607484, + 0.0011434250001912006, + 0.0010872666010982358, + 0.0010607415999402293, + 0.0010772583991638385, + 0.0011192000005394221, + 0.0010685583998565561, + 0.0010600499997963197, + 0.0010602249996736646, + 0.0010607999996864238 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/KELTNER_CHANNELS/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/KELTNER_CHANNELS/ta]", + "params": { + "indicator": "KELTNER_CHANNELS", + "library": "ta" + }, + "param": "Volatility/KELTNER_CHANNELS/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002330608399643097, + "max": 0.0025369084003614263, + "mean": 0.002398291689969483, + "stddev": 5.8368881373361285e-05, + "rounds": 20, + "median": 0.002381170800072141, + "iqr": 8.370420036953822e-05, + "q1": 0.00234762089967262, + "q3": 0.0024313251000421584, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.002330608399643097, + "hd15iqr": 0.0025369084003614263, + "ops": 416.96345952511075, + "total": 0.04796583379938966, + "data": [ + 0.0023810165992472323, + 0.00238132500089705, + 0.002348383399657905, + 0.0024432249992969446, + 0.0023426750005455686, + 0.002349116599361878, + 0.002384758400148712, + 0.002330608399643097, + 0.0025066418005735614, + 0.0025369084003614263, + 0.002409024999360554, + 0.0024185084010241555, + 0.0024290834000566973, + 0.002477650000946596, + 0.0023468583996873347, + 0.0023457749994122423, + 0.0023804582000593656, + 0.002340674999868497, + 0.002379574999213219, + 0.00243356680002762 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/DONCHIAN/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/DONCHIAN/ferro_ta]", + "params": { + "indicator": "DONCHIAN", + "library": "ferro_ta" + }, + "param": "Volatility/DONCHIAN/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.001986816599674057, + "max": 0.002450374999898486, + "mean": 0.002223842889870866, + "stddev": 0.00015215121194322317, + "rounds": 20, + "median": 0.0022443540998210664, + "iqr": 0.0002815581996401303, + "q1": 0.002073179199942388, + "q3": 0.002354737399582518, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.001986816599674057, + "hd15iqr": 0.002450374999898486, + "ops": 449.6720539723325, + "total": 0.04447685779741732, + "data": [ + 0.002450374999898486, + 0.0024028749990975483, + 0.0023195082001620905, + 0.0023973999996087514, + 0.002389966599002946, + 0.002317924999806564, + 0.0023925582005176692, + 0.002309899999818299, + 0.002235241599555593, + 0.0022958250003284773, + 0.00225346660008654, + 0.002216449999832548, + 0.0021544581992202438, + 0.0020580500000505707, + 0.0020883083998342045, + 0.0021527750010136514, + 0.002022058400325477, + 0.002016516600269824, + 0.0020163833993137813, + 0.001986816599674057 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/DONCHIAN/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/DONCHIAN/pandas_ta]", + "params": { + "indicator": "DONCHIAN", + "library": "pandas_ta" + }, + "param": "Volatility/DONCHIAN/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0031616000007488763, + "max": 0.003889141599938739, + "mean": 0.0033899600100994578, + "stddev": 0.00023387357065888804, + "rounds": 20, + "median": 0.0032737334004195873, + "iqr": 0.00038460420037154117, + "q1": 0.0032013332995120434, + "q3": 0.0035859374998835846, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0031616000007488763, + "hd15iqr": 0.003889141599938739, + "ops": 294.9887305516212, + "total": 0.06779920020198915, + "data": [ + 0.003215100000670645, + 0.0031995415993151255, + 0.003216025000438094, + 0.003179416801140178, + 0.0032513084006495774, + 0.0032031249997089618, + 0.0032198583998251707, + 0.0031616000007488763, + 0.0031986834001145326, + 0.003182599999126978, + 0.003296158400189597, + 0.003412383400427643, + 0.0034024499997030943, + 0.0034140165997087026, + 0.0037042915995698423, + 0.0037159668005187995, + 0.003889141599938739, + 0.0035664833994815126, + 0.003765658200427424, + 0.0036053916002856566 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/DONCHIAN/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/DONCHIAN/ta]", + "params": { + "indicator": "DONCHIAN", + "library": "ta" + }, + "param": "Volatility/DONCHIAN/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0032218082007602787, + "max": 0.003995674999896437, + "mean": 0.003439732059996459, + "stddev": 0.00018333853696434637, + "rounds": 20, + "median": 0.0034203292001620863, + "iqr": 0.0002406750005320645, + "q1": 0.003288658299425151, + "q3": 0.0035293332999572157, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0032218082007602787, + "hd15iqr": 0.003995674999896437, + "ops": 290.7203184892923, + "total": 0.06879464119992917, + "data": [ + 0.0035007915997994133, + 0.0033796916002756918, + 0.0033533666006405837, + 0.003678808399126865, + 0.0035668249998707323, + 0.003223058200092055, + 0.003442850000283215, + 0.003995674999896437, + 0.0032568581998930314, + 0.00343307500006631, + 0.0035038750007515772, + 0.00348705840006005, + 0.0032857415993930773, + 0.0033763915998861194, + 0.003554791599162854, + 0.003562733400030993, + 0.003272083400224801, + 0.003407583400257863, + 0.003291574999457225, + 0.0032218082007602787 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SUPERTREND/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SUPERTREND/ferro_ta]", + "params": { + "indicator": "SUPERTREND", + "library": "ferro_ta" + }, + "param": "Volatility/SUPERTREND/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012308165998547338, + "max": 0.001584591600112617, + "mean": 0.001340026260004379, + "stddev": 9.093827934437599e-05, + "rounds": 20, + "median": 0.0013218332998803815, + "iqr": 0.00012663750021602027, + "q1": 0.001269049999973504, + "q3": 0.0013956875001895242, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0012308165998547338, + "hd15iqr": 0.001584591600112617, + "ops": 746.254032362569, + "total": 0.026800525200087577, + "data": [ + 0.0012681415988481603, + 0.0013957834002212622, + 0.0013214916005381383, + 0.0012829666011384688, + 0.0012308165998547338, + 0.0012672500000917354, + 0.001331716800632421, + 0.0013106918006087654, + 0.0012695249999524095, + 0.0012696834004600533, + 0.0012685749999945984, + 0.0014656334009487183, + 0.0014512749999994411, + 0.001584591600112617, + 0.0013248499992187135, + 0.0013955916001577862, + 0.001322174999222625, + 0.0012329334000241942, + 0.0014253917994210496, + 0.001381441598641686 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SUPERTREND/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SUPERTREND/pandas_ta]", + "params": { + "indicator": "SUPERTREND", + "library": "pandas_ta" + }, + "param": "Volatility/SUPERTREND/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.6250263499998254, + "max": 0.6503970999998273, + "mean": 0.6371347046198934, + "stddev": 0.006190457908103897, + "rounds": 20, + "median": 0.6371011874995021, + "iqr": 0.008239416801370747, + "q1": 0.6332614957995247, + "q3": 0.6415009126008955, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.6250263499998254, + "hd15iqr": 0.6503970999998273, + "ops": 1.569526809242933, + "total": 12.742694092397869, + "data": [ + 0.6454465917995549, + 0.6366648915995029, + 0.641237091801304, + 0.6403032999995049, + 0.6325487084002817, + 0.6250263499998254, + 0.6351235833993997, + 0.6307996167990495, + 0.6339664915998583, + 0.6378470583993476, + 0.6424798584004747, + 0.6375374833995011, + 0.6354172499995911, + 0.6503970999998273, + 0.6440234584006248, + 0.6417647334004869, + 0.6385052666009869, + 0.6335530832002405, + 0.632969908398809, + 0.6270822667996981 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/CHOPPINESS_INDEX/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/CHOPPINESS_INDEX/ferro_ta]", + "params": { + "indicator": "CHOPPINESS_INDEX", + "library": "ferro_ta" + }, + "param": "Volatility/CHOPPINESS_INDEX/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0022348832004354335, + "max": 0.0026826916000572965, + "mean": 0.0023978708199138055, + "stddev": 0.0001602803375209451, + "rounds": 20, + "median": 0.0023250581994943786, + "iqr": 0.00031332090002251806, + "q1": 0.002258224999968661, + "q3": 0.002571545899991179, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0022348832004354335, + "hd15iqr": 0.0026826916000572965, + "ops": 417.03664421586575, + "total": 0.04795741639827611, + "data": [ + 0.0025867167991236784, + 0.002434483400429599, + 0.0022980081994319335, + 0.00228262500022538, + 0.0022583583995583467, + 0.002258091600378975, + 0.0022773082004277968, + 0.0022403000009944664, + 0.0022527081993757745, + 0.0022348832004354335, + 0.002267616799508687, + 0.002258074999554083, + 0.002588724999804981, + 0.0025899833999574184, + 0.0026826916000572965, + 0.0023521081995568236, + 0.0025563750008586795, + 0.0025106916000368074, + 0.002365441799338441, + 0.0026622249992215075 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/CHOPPINESS_INDEX/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/CHOPPINESS_INDEX/pandas_ta]", + "params": { + "indicator": "CHOPPINESS_INDEX", + "library": "pandas_ta" + }, + "param": "Volatility/CHOPPINESS_INDEX/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.004481349998968653, + "max": 0.005167391800205224, + "mean": 0.0047699337697849845, + "stddev": 0.00020711525826139535, + "rounds": 20, + "median": 0.0047310041001765064, + "iqr": 0.0003366667006048374, + "q1": 0.00461683749963413, + "q3": 0.004953504200238967, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.004481349998968653, + "hd15iqr": 0.005167391800205224, + "ops": 209.6465167576273, + "total": 0.09539867539569968, + "data": [ + 0.00484148340037791, + 0.004990125000767875, + 0.005032983400451485, + 0.005043058400042355, + 0.004916883399710059, + 0.004803466598968953, + 0.004649641799915116, + 0.004625499999383465, + 0.004705574999388773, + 0.004709233199537266, + 0.004752775000815746, + 0.005067899999266956, + 0.005167391800205224, + 0.004771008400712162, + 0.004485974999261089, + 0.004481349998968653, + 0.004570233199046925, + 0.004522674999316223, + 0.004653241799678654, + 0.004608174999884795 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/ferro_ta]", + "params": { + "indicator": "OBV", + "library": "ferro_ta" + }, + "param": "Volume/OBV/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00043845000036526474, + "max": 0.0004696083997259848, + "mean": 0.00044876540981931613, + "stddev": 9.267561601188064e-06, + "rounds": 20, + "median": 0.00044586239964701236, + "iqr": 1.0299998393747956e-05, + "q1": 0.000442000000475673, + "q3": 0.00045229999886942096, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.00043845000036526474, + "hd15iqr": 0.0004685584004619159, + "ops": 2228.3357364878557, + "total": 0.008975308196386322, + "data": [ + 0.0004696083997259848, + 0.0004685584004619159, + 0.0004643666004994884, + 0.000450758398801554, + 0.0004472665998036973, + 0.000444441799481865, + 0.00044370000105118377, + 0.00044464160018833356, + 0.00044190000044181944, + 0.00044251660001464187, + 0.00044133339979453013, + 0.00044210000050952657, + 0.0004414415991050191, + 0.00044786660000681875, + 0.0004538415989372879, + 0.00045544999884441497, + 0.00044708319910569116, + 0.0004497999994782731, + 0.00044018339976901186, + 0.00043845000036526474 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/talib]", + "params": { + "indicator": "OBV", + "library": "talib" + }, + "param": "Volume/OBV/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004390834001242183, + "max": 0.0004622834007022902, + "mean": 0.00044560711008671204, + "stddev": 5.417767963070005e-06, + "rounds": 20, + "median": 0.0004446083003131207, + "iqr": 5.28329983353617e-06, + "q1": 0.00044220420022611506, + "q3": 0.00044748750005965123, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0004390834001242183, + "hd15iqr": 0.0004622834007022902, + "ops": 2244.12936275951, + "total": 0.008912142201734242, + "data": [ + 0.00045304999948712064, + 0.00044975000055273994, + 0.0004456499998923391, + 0.00044487499981187283, + 0.0004443416008143686, + 0.00045146680058678613, + 0.0004622834007022902, + 0.00044219160045031456, + 0.00044519179937196895, + 0.00044309999939287084, + 0.0004432918009115383, + 0.00044769999949494376, + 0.00044192499917699023, + 0.0004431916007888503, + 0.00044221680000191557, + 0.0004458666007849388, + 0.00043993339932058007, + 0.0004390834001242183, + 0.00043975839944323527, + 0.00044727500062435865 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/pandas_ta]", + "params": { + "indicator": "OBV", + "library": "pandas_ta" + }, + "param": "Volume/OBV/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005382999996072612, + "max": 0.0007260332000441849, + "mean": 0.0005835370201384649, + "stddev": 4.557214154853587e-05, + "rounds": 20, + "median": 0.0005757333005021792, + "iqr": 3.7145900569157805e-05, + "q1": 0.0005548749002628028, + "q3": 0.0005920208008319606, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0005382999996072612, + "hd15iqr": 0.0006548418008605949, + "ops": 1713.6873334321008, + "total": 0.011670740402769298, + "data": [ + 0.0005933000007644296, + 0.0007260332000441849, + 0.0006322582004941069, + 0.0005796750003355556, + 0.0005802250001579523, + 0.0005655331988236867, + 0.00056124159891624, + 0.0005763000008300878, + 0.0005751666001742705, + 0.0005540915997698903, + 0.0005556582007557153, + 0.0005782332009403035, + 0.0006202415999723599, + 0.0005907416008994915, + 0.0005401332004112191, + 0.0005400499998359009, + 0.0005501331994310022, + 0.0005382999996072612, + 0.0005585831997450442, + 0.0006548418008605949 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/ta]", + "params": { + "indicator": "OBV", + "library": "ta" + }, + "param": "Volume/OBV/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004843750008149073, + "max": 0.0005456833998323419, + "mean": 0.0005027679199702107, + "stddev": 1.7131541777100625e-05, + "rounds": 20, + "median": 0.0004974083996785339, + "iqr": 2.0170798961771652e-05, + "q1": 0.0004906292007945013, + "q3": 0.0005107999997562729, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0004843750008149073, + "hd15iqr": 0.0005456833998323419, + "ops": 1988.989273737375, + "total": 0.010055358399404213, + "data": [ + 0.0004976167998393066, + 0.0004913668002700433, + 0.0005215500001213514, + 0.0004916834004689008, + 0.0004843750008149073, + 0.0005387665994931012, + 0.0005087999990792014, + 0.000509733401122503, + 0.000510333399870433, + 0.00048661659966455775, + 0.0004939917998854071, + 0.0004898916013189591, + 0.0005456833998323419, + 0.00048740819911472497, + 0.00048679999890737234, + 0.000491616599902045, + 0.0004985916006262414, + 0.0005112665996421129, + 0.0005120665999129414, + 0.0004971999995177611 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/tulipy]", + "params": { + "indicator": "OBV", + "library": "tulipy" + }, + "param": "Volume/OBV/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00043980839982395994, + "max": 0.0006727831991156563, + "mean": 0.0004905370897904504, + "stddev": 6.131506669180998e-05, + "rounds": 20, + "median": 0.0004603666988259647, + "iqr": 6.892090023029589e-05, + "q1": 0.00044799169991165404, + "q3": 0.0005169126001419499, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00043980839982395994, + "hd15iqr": 0.0006727831991156563, + "ops": 2038.581833694133, + "total": 0.009810741795809009, + "data": [ + 0.0006727831991156563, + 0.0005180918000405654, + 0.0004920999999740161, + 0.0005844499988597818, + 0.0004644499989808537, + 0.00045549999922513964, + 0.0004464917990844697, + 0.0005642499992973172, + 0.0005157334002433345, + 0.0005450334007036872, + 0.0005041166004957631, + 0.0004679165998823009, + 0.00045072499924572187, + 0.00044949160073883834, + 0.00044365840003592896, + 0.00043980839982395994, + 0.0004446332008228637, + 0.0004562833986710757, + 0.0004524834002950229, + 0.0004427416002727114 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/finta]", + "params": { + "indicator": "OBV", + "library": "finta" + }, + "param": "Volume/OBV/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.004183975000341888, + "max": 0.005119166799704544, + "mean": 0.004400687499946798, + "stddev": 0.000245053274824142, + "rounds": 20, + "median": 0.0043253999006992675, + "iqr": 0.0002921334002166983, + "q1": 0.004211833299632418, + "q3": 0.004503966699849116, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.004183975000341888, + "hd15iqr": 0.005119166799704544, + "ops": 227.2372214596218, + "total": 0.08801374999893596, + "data": [ + 0.00419231679989025, + 0.004199283399793785, + 0.004296791600063443, + 0.00420811660005711, + 0.004183975000341888, + 0.004230516598909162, + 0.005119166799704544, + 0.004815191600937396, + 0.004415608200361021, + 0.004716625000583008, + 0.004430375000811182, + 0.004368999999132939, + 0.004316216601000633, + 0.0045117499990738, + 0.004259108399855905, + 0.004191641799116042, + 0.004499266599304974, + 0.004334583200397901, + 0.004215549999207724, + 0.0045086668003932575 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/ferro_ta]", + "params": { + "indicator": "AD", + "library": "ferro_ta" + }, + "param": "Volume/AD/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002599750005174428, + "max": 0.0002901168001699261, + "mean": 0.0002728962800028967, + "stddev": 8.28421679894293e-06, + "rounds": 20, + "median": 0.0002734749999945052, + "iqr": 1.5166700177360315e-05, + "q1": 0.0002649583999300376, + "q3": 0.0002801251001073979, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0002599750005174428, + "hd15iqr": 0.0002901168001699261, + "ops": 3664.395864939549, + "total": 0.005457925600057934, + "data": [ + 0.00028036680014338345, + 0.00027988340007141234, + 0.00028270000038901346, + 0.000273833199753426, + 0.0002653249990544282, + 0.00028239179955562576, + 0.0002731168002355844, + 0.0002770915991277434, + 0.0002901168001699261, + 0.0002689834000193514, + 0.0002634000004036352, + 0.00028065819933544847, + 0.0002698668002267368, + 0.0002715334005188197, + 0.0002739500007010065, + 0.0002625749999424443, + 0.00026459180080564695, + 0.00027535839908523487, + 0.0002599750005174428, + 0.0002622082000016235 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/talib]", + "params": { + "indicator": "AD", + "library": "talib" + }, + "param": "Volume/AD/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002755334004177712, + "max": 0.00029946680006105454, + "mean": 0.000282004210021114, + "stddev": 7.022623873613387e-06, + "rounds": 20, + "median": 0.0002795209002215415, + "iqr": 1.1220700253033989e-05, + "q1": 0.00027659589977702126, + "q3": 0.00028781660003005525, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0002755334004177712, + "hd15iqr": 0.00029946680006105454, + "ops": 3546.0463513120208, + "total": 0.0056400842004222795, + "data": [ + 0.00029946680006105454, + 0.00029063340043649076, + 0.0002903415996115655, + 0.00029091680044075475, + 0.0002810249992762692, + 0.0002928834001068026, + 0.00028529160044854507, + 0.0002788249999866821, + 0.0002766249992419034, + 0.00028005000058328733, + 0.00027988340007141234, + 0.00027684179949574175, + 0.00027739180077333, + 0.0002792168001178652, + 0.00027648319955915214, + 0.00027982500032521786, + 0.0002755334004177712, + 0.0002759249997325242, + 0.0002765668003121391, + 0.00027635839942377063 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/pandas_ta]", + "params": { + "indicator": "AD", + "library": "pandas_ta" + }, + "param": "Volume/AD/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00040585819951957094, + "max": 0.00042926680034724993, + "mean": 0.0004144779302441748, + "stddev": 6.256850847458707e-06, + "rounds": 20, + "median": 0.0004125208004552405, + "iqr": 8.53329984238369e-06, + "q1": 0.0004097792007087264, + "q3": 0.0004183125005511101, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.00040585819951957094, + "hd15iqr": 0.00042926680034724993, + "ops": 2412.67369630727, + "total": 0.008289558604883496, + "data": [ + 0.00041919180075637995, + 0.00041897500050254167, + 0.00041665000026114285, + 0.0004116916010389104, + 0.00040909160015871747, + 0.0004120750003494322, + 0.0004129666005610488, + 0.00040585819951957094, + 0.00041296679992228744, + 0.0004100168007425964, + 0.00040954160067485645, + 0.000408758400590159, + 0.0004083749998244457, + 0.0004198249996989034, + 0.0004277665997506119, + 0.0004165665988693945, + 0.00042926680034724993, + 0.0004176500005996786, + 0.00041175840015057475, + 0.00041056680056499316 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/ta]", + "params": { + "indicator": "AD", + "library": "ta" + }, + "param": "Volume/AD/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005913917993893847, + "max": 0.0008743834012420848, + "mean": 0.0007945912598370341, + "stddev": 8.45128355074644e-05, + "rounds": 20, + "median": 0.0008259332993475254, + "iqr": 1.8700000509852543e-05, + "q1": 0.0008201207994716242, + "q3": 0.0008388207999814768, + "iqr_outliers": 5, + "stddev_outliers": 3, + "outliers": "3;5", + "ld15iqr": 0.0008197999995900318, + "hd15iqr": 0.0008743834012420848, + "ops": 1258.5086830744826, + "total": 0.015891825196740685, + "data": [ + 0.0007356166010140441, + 0.0008369000002858229, + 0.0008407415996771305, + 0.0008445667990599759, + 0.0008264250005595386, + 0.0008226749996538274, + 0.0008268083998700604, + 0.0008239249989856034, + 0.0008257499997853301, + 0.0008261165989097208, + 0.000848508399212733, + 0.0008743834012420848, + 0.000842450000345707, + 0.0008229750004829839, + 0.0008204415993532166, + 0.0008197999995900318, + 0.0008280833993921987, + 0.0006403416002285667, + 0.0005939249997027219, + 0.0005913917993893847 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/tulipy]", + "params": { + "indicator": "AD", + "library": "tulipy" + }, + "param": "Volume/AD/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000279741600388661, + "max": 0.00040370000060647727, + "mean": 0.0003273329001240199, + "stddev": 4.1325085055011836e-05, + "rounds": 20, + "median": 0.00031117079997784454, + "iqr": 7.648329992662186e-05, + "q1": 0.0002924250002251938, + "q3": 0.00036890830015181566, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.000279741600388661, + "hd15iqr": 0.00040370000060647727, + "ops": 3054.9938598323597, + "total": 0.006546658002480399, + "data": [ + 0.0003858334006508812, + 0.0003689165998366661, + 0.0002849833996151574, + 0.0002815165993524715, + 0.000279741600388661, + 0.00036890000046696514, + 0.00035764159983955326, + 0.00030391659965971484, + 0.00029158320103306323, + 0.0003301331991679035, + 0.00040370000060647727, + 0.00031614160106983034, + 0.00033469160116510465, + 0.00037171660078456626, + 0.00030187499942258, + 0.00029326679941732436, + 0.0003061999988858588, + 0.00029646680050063876, + 0.00028295000083744526, + 0.00038648339977953583 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/ADOSC/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/ADOSC/ferro_ta]", + "params": { + "indicator": "ADOSC", + "library": "ferro_ta" + }, + "param": "Volume/ADOSC/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004740250005852431, + "max": 0.0010638084000675007, + "mean": 0.0005542666900873883, + "stddev": 0.00012415681442168097, + "rounds": 20, + "median": 0.0005196916994464119, + "iqr": 5.027920051361436e-05, + "q1": 0.0005112124999868684, + "q3": 0.0005614917005004827, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0004740250005852431, + "hd15iqr": 0.0010638084000675007, + "ops": 1804.1856346127086, + "total": 0.011085333801747765, + "data": [ + 0.000510924999252893, + 0.0005764500005170703, + 0.000563358400540892, + 0.0004886499998974613, + 0.0005225415996392257, + 0.0005483583998284302, + 0.00048346659896196796, + 0.0010638084000675007, + 0.000597524999466259, + 0.0005197833990678192, + 0.0005131834011990577, + 0.0005142000009072945, + 0.0005671834005624987, + 0.0005068918006145395, + 0.0005147417992702686, + 0.0005195999998250045, + 0.0005115000007208437, + 0.0005596250004600734, + 0.0004740250005852431, + 0.0005295166003634222 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/ADOSC/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/ADOSC/talib]", + "params": { + "indicator": "ADOSC", + "library": "talib" + }, + "param": "Volume/ADOSC/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00038997499941615386, + "max": 0.0004927084009977989, + "mean": 0.00041430250013945624, + "stddev": 2.8144174045617044e-05, + "rounds": 20, + "median": 0.0004044375004014, + "iqr": 3.94540998968296e-05, + "q1": 0.0003929083999537397, + "q3": 0.0004323624998505693, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00038997499941615386, + "hd15iqr": 0.0004927084009977989, + "ops": 2413.6953063604374, + "total": 0.008286050002789124, + "data": [ + 0.00043894999980693684, + 0.0004469334002351388, + 0.00042694180010585117, + 0.0004927084009977989, + 0.0004377831995952874, + 0.0003927334008039907, + 0.0003914584012818523, + 0.00039225000073201955, + 0.00046066659997450187, + 0.0004109500005142763, + 0.00040874159894883634, + 0.0004031165997730568, + 0.0003987250005593523, + 0.0003930833991034888, + 0.0003914999993867241, + 0.00038997499941615386, + 0.0004163165998761542, + 0.00040575840102974325, + 0.0003933082000003196, + 0.00039415000064764173 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/ADOSC/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/ADOSC/pandas_ta]", + "params": { + "indicator": "ADOSC", + "library": "pandas_ta" + }, + "param": "Volume/ADOSC/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005246750006335787, + "max": 0.000554333400214091, + "mean": 0.0005358004499430535, + "stddev": 7.956827548318061e-06, + "rounds": 20, + "median": 0.00053412080014823, + "iqr": 1.1724900105036795e-05, + "q1": 0.0005300625998643227, + "q3": 0.0005417874999693595, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005246750006335787, + "hd15iqr": 0.000554333400214091, + "ops": 1866.3664804803411, + "total": 0.010716008998861071, + "data": [ + 0.000554333400214091, + 0.0005474249992403202, + 0.0005313999994541518, + 0.0005378000001655892, + 0.0005351750005502254, + 0.0005266667998512275, + 0.0005353667991585098, + 0.0005330665997462347, + 0.0005246750006335787, + 0.0005382499992265366, + 0.0005467918002977967, + 0.0005289417997119017, + 0.0005328750004991889, + 0.0005420000001322478, + 0.0005415749998064712, + 0.0005311834000167436, + 0.0005422583999461494, + 0.0005264666004222817, + 0.0005275083996821195, + 0.0005322500001057051 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/ADOSC/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/ADOSC/tulipy]", + "params": { + "indicator": "ADOSC", + "library": "tulipy" + }, + "param": "Volume/ADOSC/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003618333998019807, + "max": 0.00046870840014889836, + "mean": 0.0003758824898977764, + "stddev": 2.3535785707423034e-05, + "rounds": 20, + "median": 0.0003699665998283308, + "iqr": 1.4941801055101678e-05, + "q1": 0.0003635415996541269, + "q3": 0.0003784834007092286, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0003618333998019807, + "hd15iqr": 0.00046870840014889836, + "ops": 2660.40591641275, + "total": 0.0075176497979555276, + "data": [ + 0.0003707249998115003, + 0.0003701499997987412, + 0.000383000000147149, + 0.00036360819940455257, + 0.0003630081992014311, + 0.0003697831998579204, + 0.0003655249995063059, + 0.0003619416005676612, + 0.0003850166001939215, + 0.0003936833993066102, + 0.0003725333997863345, + 0.0003618333998019807, + 0.00036347499990370126, + 0.00036328339920146393, + 0.00046870840014889836, + 0.00038141680124681443, + 0.0003701581998029724, + 0.00037555000017164275, + 0.00036566659982781855, + 0.00036858340026810763 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/ferro_ta]", + "params": { + "indicator": "MFI", + "library": "ferro_ta" + }, + "param": "Volume/MFI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003435916005400941, + "max": 0.0004976500000339002, + "mean": 0.00038282624998828394, + "stddev": 4.5905469555997866e-05, + "rounds": 20, + "median": 0.000362887499795761, + "iqr": 4.127510037505995e-05, + "q1": 0.00035219580022385346, + "q3": 0.0003934709005989134, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.0003435916005400941, + "hd15iqr": 0.00048079160042107104, + "ops": 2612.151073837293, + "total": 0.00765652499976568, + "data": [ + 0.0003435916005400941, + 0.00044767500075977297, + 0.0004007417999673635, + 0.0004976500000339002, + 0.00048079160042107104, + 0.00037523339997278524, + 0.00035572499909903855, + 0.00037826659972779454, + 0.00037616679910570385, + 0.0003657166002085432, + 0.0003501500003039837, + 0.0003570250002667308, + 0.0003862000012304634, + 0.00034449180093361066, + 0.00034869160008383916, + 0.00035424160014372317, + 0.0003584249992854893, + 0.00034494159917812793, + 0.00043074159912066535, + 0.0003600583993829787 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/talib]", + "params": { + "indicator": "MFI", + "library": "talib" + }, + "param": "Volume/MFI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007110500009730458, + "max": 0.0007499334009480662, + "mean": 0.0007252858501306036, + "stddev": 1.1353708232142423e-05, + "rounds": 20, + "median": 0.0007248082998557948, + "iqr": 1.66498000908178e-05, + "q1": 0.0007144333998439833, + "q3": 0.0007310831999348011, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0007110500009730458, + "hd15iqr": 0.0007499334009480662, + "ops": 1378.7667301380939, + "total": 0.014505717002612073, + "data": [ + 0.0007383415999356657, + 0.0007256333992700092, + 0.0007283166007255204, + 0.000724666599126067, + 0.0007298334006918594, + 0.0007413750005071052, + 0.0007499334009480662, + 0.0007430915997247211, + 0.0007313331996556371, + 0.0007198084000265226, + 0.0007134918007068336, + 0.0007150249992264434, + 0.0007249500005855225, + 0.0007138418004615232, + 0.0007211083997390233, + 0.000730833200213965, + 0.0007130668003810569, + 0.0007174083992140367, + 0.0007110500009730458, + 0.0007126084004994482 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/pandas_ta]", + "params": { + "indicator": "MFI", + "library": "pandas_ta" + }, + "param": "Volume/MFI/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008402917999774217, + "max": 0.000871641599223949, + "mean": 0.000851034579827683, + "stddev": 1.0225624151585104e-05, + "rounds": 20, + "median": 0.0008462374993541743, + "iqr": 1.676249885349543e-05, + "q1": 0.0008438458004093262, + "q3": 0.0008606082992628217, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0008402917999774217, + "hd15iqr": 0.000871641599223949, + "ops": 1175.0403846133718, + "total": 0.01702069159655366, + "data": [ + 0.0008603665992268361, + 0.000871641599223949, + 0.0008611833996837959, + 0.0008471331995679066, + 0.0008478750009089708, + 0.0008402917999774217, + 0.0008456667987047694, + 0.0008525918005034327, + 0.0008608499992988072, + 0.0008468082000035792, + 0.000845024999580346, + 0.0008420999991358257, + 0.0008422081999015063, + 0.0008435166004346683, + 0.000845116599521134, + 0.0008679915990796871, + 0.0008695250013261102, + 0.0008448584005236626, + 0.0008441750003839843, + 0.0008417667995672673 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/ta]", + "params": { + "indicator": "MFI", + "library": "ta" + }, + "param": "Volume/MFI/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.41174320819991406, + "max": 0.4314239750005072, + "mean": 0.4204762075199687, + "stddev": 0.005099123498887338, + "rounds": 20, + "median": 0.42081642500052113, + "iqr": 0.0069879126007436065, + "q1": 0.41601936249935534, + "q3": 0.42300727510009895, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.41174320819991406, + "hd15iqr": 0.4314239750005072, + "ops": 2.3782558492385313, + "total": 8.409524150399374, + "data": [ + 0.41483689159940695, + 0.4156232749999617, + 0.4234342918003676, + 0.4161888999995426, + 0.41699979179975344, + 0.4142426917998819, + 0.4201439416006906, + 0.41584982499916806, + 0.4225802583998302, + 0.42191900000034366, + 0.4180418499992811, + 0.4272655249995296, + 0.4314239750005072, + 0.4225033334005275, + 0.41174320819991406, + 0.42601544999924956, + 0.421724541799631, + 0.420297933400434, + 0.4213349166006083, + 0.4273545500007458 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/tulipy]", + "params": { + "indicator": "MFI", + "library": "tulipy" + }, + "param": "Volume/MFI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006424584003980272, + "max": 0.0007229666007333435, + "mean": 0.0006612312400829978, + "stddev": 2.244172854967062e-05, + "rounds": 20, + "median": 0.0006554667997988872, + "iqr": 2.6879300276050387e-05, + "q1": 0.0006445915998483543, + "q3": 0.0006714709001244047, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0006424584003980272, + "hd15iqr": 0.0007229666007333435, + "ops": 1512.3302399845475, + "total": 0.013224624801659956, + "data": [ + 0.0007114416002877988, + 0.0007229666007333435, + 0.0006562668000697158, + 0.0006721084006130696, + 0.0006591166005819104, + 0.0006734084003255702, + 0.0006799166003474966, + 0.0006708333996357397, + 0.0006437081989133731, + 0.0006483332006609998, + 0.0006437165997340344, + 0.0006435666000470519, + 0.0006426416002796032, + 0.0006594749997020699, + 0.0006608334006159566, + 0.0006546667995280586, + 0.0006424584003980272, + 0.0006454665999626741, + 0.0006461833996581845, + 0.0006475165995652787 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/finta]", + "params": { + "indicator": "MFI", + "library": "finta" + }, + "param": "Volume/MFI/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.37874110840057257, + "max": 1.1485744834004437, + "mean": 0.6378114891901351, + "stddev": 0.25539338832654623, + "rounds": 20, + "median": 0.632249387599586, + "iqr": 0.4003730042000826, + "q1": 0.4001616667002963, + "q3": 0.8005346709003789, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.37874110840057257, + "hd15iqr": 1.1485744834004437, + "ops": 1.5678613774577126, + "total": 12.7562297838027, + "data": [ + 0.41436194160050943, + 0.3834217832001741, + 0.6196866417987621, + 0.4250675166011206, + 0.38740634999994655, + 1.0543201249995036, + 1.1485744834004437, + 0.8950173416000325, + 0.8131527668010676, + 1.0214627583991387, + 0.7335472916005529, + 0.7273777668000548, + 0.7161186584009556, + 0.78791657499969, + 0.64481213340041, + 0.42369262499996696, + 0.4114484666002681, + 0.3888748668003245, + 0.37874110840057257, + 0.3812285833992064 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/VWAP/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/VWAP/ferro_ta]", + "params": { + "indicator": "VWAP", + "library": "ferro_ta" + }, + "param": "Volume/VWAP/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00027074159879703075, + "max": 0.00028697500092675907, + "mean": 0.0002740724899922498, + "stddev": 4.748208113022087e-06, + "rounds": 20, + "median": 0.00027216249945922756, + "iqr": 2.966600004583532e-06, + "q1": 0.00027125000051455574, + "q3": 0.0002742166005191393, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.00027074159879703075, + "hd15iqr": 0.00028217500075697897, + "ops": 3648.669737076779, + "total": 0.005481449799844995, + "data": [ + 0.00028217500075697897, + 0.00028394999972078947, + 0.00028697500092675907, + 0.0002749416002188809, + 0.00027261680079391224, + 0.00027324159891577436, + 0.0002712250003241934, + 0.00027684179949574175, + 0.0002734916008193977, + 0.00027137500001117587, + 0.0002709499996853992, + 0.0002721415992709808, + 0.00027170000103069467, + 0.00027081659936811775, + 0.0002722666002227925, + 0.0002707416002522223, + 0.000271799998881761, + 0.0002721833996474743, + 0.0002712750007049181, + 0.00027074159879703075 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/VWAP/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/VWAP/pandas_ta]", + "params": { + "indicator": "VWAP", + "library": "pandas_ta" + }, + "param": "Volume/VWAP/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.010450675000902266, + "max": 0.010919149999972433, + "mean": 0.010568513329926645, + "stddev": 0.000122684078557069, + "rounds": 20, + "median": 0.010518804199818987, + "iqr": 7.465829912689514e-05, + "q1": 0.0105034208005236, + "q3": 0.010578079099650495, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.010450675000902266, + "hd15iqr": 0.010723483399488032, + "ops": 94.62068777150711, + "total": 0.21137026659853292, + "data": [ + 0.010450675000902266, + 0.010517958400305361, + 0.010575699999753852, + 0.010556683200411499, + 0.010589425000944175, + 0.010551850000047125, + 0.010501033200125676, + 0.010550091600453015, + 0.010475591600697953, + 0.01050743339874316, + 0.010505808400921524, + 0.010478174999298063, + 0.010519649999332614, + 0.010490774999198038, + 0.010516591600026003, + 0.010512166799162514, + 0.010919149999972433, + 0.010847566799202468, + 0.010723483399488032, + 0.010580458199547138 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/VWAP/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/VWAP/finta]", + "params": { + "indicator": "VWAP", + "library": "finta" + }, + "param": "Volume/VWAP/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008319583997945301, + "max": 0.0014169916001264937, + "mean": 0.0012304224901163252, + "stddev": 0.00019857965737331594, + "rounds": 20, + "median": 0.0013405958998191636, + "iqr": 0.00022344580065691844, + "q1": 0.0011289458001556341, + "q3": 0.0013523916008125526, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0008319583997945301, + "hd15iqr": 0.0014169916001264937, + "ops": 812.7289675154257, + "total": 0.024608449802326505, + "data": [ + 0.0012882581999292598, + 0.0012997249999898487, + 0.0013530082011129706, + 0.0011194165999768302, + 0.0009344000005512499, + 0.0008319583997945301, + 0.0008502915996359661, + 0.000865683400479611, + 0.001138475000334438, + 0.0014169916001264937, + 0.0013356249997741542, + 0.0013435583998216317, + 0.0013376333998166956, + 0.0013709584003663623, + 0.001349058399500791, + 0.0013517750005121343, + 0.0013502250003512017, + 0.0013484832001267933, + 0.001361824999912642, + 0.0013611000002129003 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/AVGPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/AVGPRICE/ferro_ta]", + "params": { + "indicator": "AVGPRICE", + "library": "ferro_ta" + }, + "param": "Price Transform/AVGPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00020409999997355043, + "max": 0.0002294249992701225, + "mean": 0.00021019540981797035, + "stddev": 6.109191668370056e-06, + "rounds": 20, + "median": 0.00020864999969489874, + "iqr": 7.883299258537607e-06, + "q1": 0.0002054542004771065, + "q3": 0.0002133374997356441, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.00020409999997355043, + "hd15iqr": 0.0002294249992701225, + "ops": 4757.47781964412, + "total": 0.004203908196359407, + "data": [ + 0.00020409999997355043, + 0.00020978320098947735, + 0.0002068749992758967, + 0.00020944999996572733, + 0.00020517499942798167, + 0.00020513339986791835, + 0.0002078499994240701, + 0.00020630840008379893, + 0.00020545000006677583, + 0.00020539160032058136, + 0.0002057666002656333, + 0.00020545840088743718, + 0.0002113583992468193, + 0.00021322499960660935, + 0.0002148081999621354, + 0.0002294249992701225, + 0.00021746679994976147, + 0.0002160915988497436, + 0.00021134159906068816, + 0.00021344999986467884 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/AVGPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/AVGPRICE/talib]", + "params": { + "indicator": "AVGPRICE", + "library": "talib" + }, + "param": "Price Transform/AVGPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002022415996179916, + "max": 0.00021817499946337194, + "mean": 0.00020749084018461872, + "stddev": 4.903867580579643e-06, + "rounds": 20, + "median": 0.00020639170106733217, + "iqr": 5.1751005230471654e-06, + "q1": 0.00020343740034149959, + "q3": 0.00020861250086454675, + "iqr_outliers": 2, + "stddev_outliers": 5, + "outliers": "5;2", + "ld15iqr": 0.0002022415996179916, + "hd15iqr": 0.0002168500010157004, + "ops": 4819.489858493184, + "total": 0.0041498168036923746, + "data": [ + 0.00021434160007629543, + 0.00021817499946337194, + 0.00021520000009331853, + 0.0002168500010157004, + 0.00020893340115435421, + 0.0002031166004599072, + 0.00020294179994380103, + 0.00020678340079030021, + 0.00020794180018128826, + 0.00020410839933902026, + 0.00020333319989731535, + 0.0002082916005747393, + 0.00020354160078568385, + 0.00020271679968573154, + 0.000205275000189431, + 0.00020729999960167333, + 0.00020559999975375832, + 0.0002022415996179916, + 0.00020600000134436415, + 0.0002071249997243285 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/AVGPRICE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/AVGPRICE/tulipy]", + "params": { + "indicator": "AVGPRICE", + "library": "tulipy" + }, + "param": "Price Transform/AVGPRICE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002041334009845741, + "max": 0.0002198917994974181, + "mean": 0.00020753834025526884, + "stddev": 4.287165413955808e-06, + "rounds": 20, + "median": 0.0002061165992927272, + "iqr": 1.6042999050114358e-06, + "q1": 0.00020543740029097535, + "q3": 0.0002070417001959868, + "iqr_outliers": 3, + "stddev_outliers": 2, + "outliers": "2;3", + "ld15iqr": 0.0002041334009845741, + "hd15iqr": 0.00021114999981364235, + "ops": 4818.386803951578, + "total": 0.004150766805105377, + "data": [ + 0.00021848340111318976, + 0.0002069334004772827, + 0.00020609159982996062, + 0.0002062250001472421, + 0.00020889180013909935, + 0.0002065334003418684, + 0.00020544160070130603, + 0.00020614159875549377, + 0.00021114999981364235, + 0.00020578319963533432, + 0.0002041334009845741, + 0.0002049334012554027, + 0.0002198917994974181, + 0.00020714999991469085, + 0.0002067082008579746, + 0.0002042084001004696, + 0.00020598340051947162, + 0.00020543319988064468, + 0.00020575000089593232, + 0.00020490000024437905 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/MEDPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/MEDPRICE/ferro_ta]", + "params": { + "indicator": "MEDPRICE", + "library": "ferro_ta" + }, + "param": "Price Transform/MEDPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00018776679935399442, + "max": 0.00020529999892460182, + "mean": 0.00019296959006169346, + "stddev": 5.201378546448543e-06, + "rounds": 20, + "median": 0.00019034169963560998, + "iqr": 7.329099753405877e-06, + "q1": 0.0001889751001726836, + "q3": 0.00019630419992608947, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.00018776679935399442, + "hd15iqr": 0.00020529999892460182, + "ops": 5182.163675013739, + "total": 0.003859391801233869, + "data": [ + 0.00019027499947696925, + 0.00019040839979425072, + 0.0001951833997736685, + 0.00019742500007851048, + 0.00019769160135183484, + 0.00020313320128479974, + 0.00019313340017106383, + 0.00018902500014519318, + 0.00020529999892460182, + 0.00019828340009553358, + 0.0001948666002135724, + 0.00018880820134654642, + 0.00018776679935399442, + 0.00018843319994630293, + 0.000194958399515599, + 0.00018895840039476753, + 0.00018899999995483085, + 0.00018899179995059966, + 0.00018924999894807116, + 0.00018850000051315874 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/MEDPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/MEDPRICE/talib]", + "params": { + "indicator": "MEDPRICE", + "library": "talib" + }, + "param": "Price Transform/MEDPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00018684999959077685, + "max": 0.00020107500022277237, + "mean": 0.00019278788997326046, + "stddev": 5.246297939029735e-06, + "rounds": 20, + "median": 0.0001915749002364464, + "iqr": 9.129200043389595e-06, + "q1": 0.00018806239968398585, + "q3": 0.00019719159972737544, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.00018684999959077685, + "hd15iqr": 0.00020107500022277237, + "ops": 5187.047797134453, + "total": 0.0038557577994652093, + "data": [ + 0.00019628320005722345, + 0.00018854160007322207, + 0.00018720840016612784, + 0.00018713339959504084, + 0.00018859999981941656, + 0.0001881915988633409, + 0.00018814159993780778, + 0.00018684999959077685, + 0.0001875083995400928, + 0.00019473340071272106, + 0.00018798319943016394, + 0.00019141660013701766, + 0.00019592500029830262, + 0.0001963165996130556, + 0.00020000820077257231, + 0.00020107500022277237, + 0.00020063340052729474, + 0.00019806659984169527, + 0.0001994083999306895, + 0.00019173320033587514 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/MEDPRICE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/MEDPRICE/tulipy]", + "params": { + "indicator": "MEDPRICE", + "library": "tulipy" + }, + "param": "Price Transform/MEDPRICE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00018770000024233014, + "max": 0.00019395000126678496, + "mean": 0.0001897804102191003, + "stddev": 1.8283249687009444e-06, + "rounds": 20, + "median": 0.000189275000593625, + "iqr": 2.862599649233738e-06, + "q1": 0.00018822909987648018, + "q3": 0.00019109169952571392, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00018770000024233014, + "hd15iqr": 0.00019395000126678496, + "ops": 5269.2477524182095, + "total": 0.003795608204382006, + "data": [ + 0.00019040000042878092, + 0.000189650000538677, + 0.00019244159921072423, + 0.00019395000126678496, + 0.0001891166000859812, + 0.00018943340110126882, + 0.00018823319987859577, + 0.00019170000014128162, + 0.00018802499980665743, + 0.00018811659974744545, + 0.00018894160020863638, + 0.00019227500015404076, + 0.00019224179995944724, + 0.00018818340031430126, + 0.0001904833989101462, + 0.00018869160121539607, + 0.00018822499987436458, + 0.00018833340000128372, + 0.00018946660129586234, + 0.00018770000024233014 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/TYPPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/TYPPRICE/ferro_ta]", + "params": { + "indicator": "TYPPRICE", + "library": "ferro_ta" + }, + "param": "Price Transform/TYPPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000195141599397175, + "max": 0.0002100583995343186, + "mean": 0.00019906456975149923, + "stddev": 4.210469085446544e-06, + "rounds": 20, + "median": 0.00019695000009960494, + "iqr": 5.5208998674061095e-06, + "q1": 0.00019624159976956436, + "q3": 0.00020176249963697047, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.000195141599397175, + "hd15iqr": 0.0002100583995343186, + "ops": 5023.495648916041, + "total": 0.003981291395029984, + "data": [ + 0.0002100583995343186, + 0.00020638339919969438, + 0.00020408340060384944, + 0.00020418339991010726, + 0.00019615819910541178, + 0.0002015999998548068, + 0.00019906660018023103, + 0.00019580000080168247, + 0.0001977499996428378, + 0.00019650839967653156, + 0.00019714999943971635, + 0.00019633319898275657, + 0.00019549159915186465, + 0.000195141599397175, + 0.00020192499941913412, + 0.00019675000075949355, + 0.00019596659985836594, + 0.00019655840005725623, + 0.00019805819902103395, + 0.00019632500043371693 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/TYPPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/TYPPRICE/talib]", + "params": { + "indicator": "TYPPRICE", + "library": "talib" + }, + "param": "Price Transform/TYPPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019475820008665323, + "max": 0.00020779999904334544, + "mean": 0.0001997458201367408, + "stddev": 4.073174496253286e-06, + "rounds": 20, + "median": 0.0001985458002309315, + "iqr": 6.462499732151649e-06, + "q1": 0.00019632920084404758, + "q3": 0.00020279170057619923, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.00019475820008665323, + "hd15iqr": 0.00020779999904334544, + "ops": 5006.3625827835895, + "total": 0.003994916402734816, + "data": [ + 0.0001994168007513508, + 0.00019671660120366142, + 0.00019475820008665323, + 0.00019633340125437825, + 0.0001972081998246722, + 0.00019868320086970925, + 0.00020206680055707694, + 0.00020351660059532152, + 0.00020409160060808063, + 0.00020779999904334544, + 0.00020701659959740937, + 0.00020165839960100128, + 0.00020558319956762717, + 0.00020019160001538694, + 0.00019515839958330616, + 0.0001953500002855435, + 0.00019840839959215373, + 0.0001983249996555969, + 0.00019632500043371693, + 0.0001963083996088244 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/TYPPRICE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/TYPPRICE/tulipy]", + "params": { + "indicator": "TYPPRICE", + "library": "tulipy" + }, + "param": "Price Transform/TYPPRICE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001964666007552296, + "max": 0.00026799179904628544, + "mean": 0.00020468501999857836, + "stddev": 1.6084123155496496e-05, + "rounds": 20, + "median": 0.00020071670005563646, + "iqr": 5.73760044062508e-06, + "q1": 0.00019772500018007123, + "q3": 0.0002034626006206963, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0001964666007552296, + "hd15iqr": 0.00022431679972214624, + "ops": 4885.555376778162, + "total": 0.004093700399971567, + "data": [ + 0.00020024179975735023, + 0.0002025417998083867, + 0.0002011916003539227, + 0.00019798319990513847, + 0.00019664160063257441, + 0.00019839999877149239, + 0.00019673339993460105, + 0.00019768339989241214, + 0.00019707500032382086, + 0.00019920820050174371, + 0.00020220839942339808, + 0.0001964666007552296, + 0.00019776660046773032, + 0.00020319180039223282, + 0.00026799179904628544, + 0.00022431679972214624, + 0.0002037334008491598, + 0.00020412500016391276, + 0.00020487500005401672, + 0.00020132499921601265 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/WCLPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/WCLPRICE/ferro_ta]", + "params": { + "indicator": "WCLPRICE", + "library": "ferro_ta" + }, + "param": "Price Transform/WCLPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019629180023912341, + "max": 0.00020217500132275746, + "mean": 0.00019859625019307713, + "stddev": 1.53488442777797e-06, + "rounds": 20, + "median": 0.00019810829980997368, + "iqr": 2.3332991986535435e-06, + "q1": 0.00019762910014833323, + "q3": 0.00019996239934698677, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.00019629180023912341, + "hd15iqr": 0.00020217500132275746, + "ops": 5035.341800400514, + "total": 0.0039719250038615424, + "data": [ + 0.00020026679994771256, + 0.00019924160005757585, + 0.00020051660103490577, + 0.00020217500132275746, + 0.0001978168002096936, + 0.00019825000053970144, + 0.00019693320064106956, + 0.00019979159987997263, + 0.00019629180023912341, + 0.00019735840032808483, + 0.00019865840004058556, + 0.00020013319881400092, + 0.0001968418000615202, + 0.00019765820034081116, + 0.0001975999999558553, + 0.0001982415997190401, + 0.00019797499990090728, + 0.0001977500010980293, + 0.00019780000002356246, + 0.0002006249997066334 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/WCLPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/WCLPRICE/talib]", + "params": { + "indicator": "WCLPRICE", + "library": "talib" + }, + "param": "Price Transform/WCLPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019606679998105392, + "max": 0.000209683200228028, + "mean": 0.0001995312598592136, + "stddev": 3.520072018498484e-06, + "rounds": 20, + "median": 0.0001986125993425958, + "iqr": 3.287398430984478e-06, + "q1": 0.00019692090063472278, + "q3": 0.00020020829906570726, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.00019606679998105392, + "hd15iqr": 0.000209683200228028, + "ops": 5011.746032704779, + "total": 0.003990625197184272, + "data": [ + 0.00020467499998630956, + 0.00020426679984666407, + 0.00019669159955810757, + 0.00019794999971054495, + 0.000209683200228028, + 0.00020044159900862725, + 0.0001999000000068918, + 0.0001993333993596025, + 0.00019706680031958968, + 0.0002036916004726663, + 0.00019866679940605536, + 0.000197666599706281, + 0.00019664160063257441, + 0.00019855839927913622, + 0.00019720000127563254, + 0.00019606679998105392, + 0.0001967750009498559, + 0.00019997499912278728, + 0.00019890839903382584, + 0.00019646659930003807 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/WCLPRICE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/WCLPRICE/tulipy]", + "params": { + "indicator": "WCLPRICE", + "library": "tulipy" + }, + "param": "Price Transform/WCLPRICE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019716680108103902, + "max": 0.00020973319915356113, + "mean": 0.00020215749987983144, + "stddev": 4.075163194410026e-06, + "rounds": 20, + "median": 0.00020112499987590127, + "iqr": 6.829299672972389e-06, + "q1": 0.0001987666000786703, + "q3": 0.0002055958997516427, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.00019716680108103902, + "hd15iqr": 0.00020973319915356113, + "ops": 4946.638143993819, + "total": 0.004043149997596629, + "data": [ + 0.00019969160057371483, + 0.00019848319934681057, + 0.00020121660054428503, + 0.00020130000048084183, + 0.0002023584005655721, + 0.00020496679935604333, + 0.0002046167996013537, + 0.00020864999969489874, + 0.00020973319915356113, + 0.00020841679943259806, + 0.0002062250001472421, + 0.00020086659933440387, + 0.00019724999874597414, + 0.00019716680108103902, + 0.0002010333992075175, + 0.00019962500082328915, + 0.00020667499920818956, + 0.00019734160014195368, + 0.0001990415999898687, + 0.00019849160016747192 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/SQRT/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/SQRT/ferro_ta]", + "params": { + "indicator": "SQRT", + "library": "ferro_ta" + }, + "param": "Math/SQRT/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002010415992117487, + "max": 0.00021231659920886158, + "mean": 0.00020500874001299962, + "stddev": 4.042310367497842e-06, + "rounds": 20, + "median": 0.00020255830022506414, + "iqr": 7.095800538081665e-06, + "q1": 0.00020185419998597354, + "q3": 0.0002089500005240552, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0002010415992117487, + "hd15iqr": 0.00021231659920886158, + "ops": 4877.840817599239, + "total": 0.004100174800259993, + "data": [ + 0.00021100000012665986, + 0.00020244159968569876, + 0.00020215839904267341, + 0.00020226660126354545, + 0.00020351660059532152, + 0.00020140839915256948, + 0.00020266659994376822, + 0.00020235000120010226, + 0.00020745840010931714, + 0.00020120839908486233, + 0.0002010415992117487, + 0.0002011165997828357, + 0.00020589999912772327, + 0.00020924999989802018, + 0.00020921680115861818, + 0.00021217500034254044, + 0.00020868319988949225, + 0.00020155000092927366, + 0.00020245000050636008, + 0.00021231659920886158 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/SQRT/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/SQRT/talib]", + "params": { + "indicator": "SQRT", + "library": "talib" + }, + "param": "Math/SQRT/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019931659917347134, + "max": 0.00020545839943224563, + "mean": 0.00020095997984753922, + "stddev": 1.726110566591338e-06, + "rounds": 20, + "median": 0.00020037079957546666, + "iqr": 1.6790996596682763e-06, + "q1": 0.00019974579990957863, + "q3": 0.0002014248995692469, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.00019931659917347134, + "hd15iqr": 0.0002043999993475154, + "ops": 4976.115148691109, + "total": 0.004019199596950784, + "data": [ + 0.0002013417994021438, + 0.00020095000072615222, + 0.00020375840103952213, + 0.00020016679918626322, + 0.0001999668005737476, + 0.000201441599347163, + 0.00020146659953752534, + 0.00019969999993918464, + 0.00020140819979133084, + 0.00019951660069637, + 0.00020545839943224563, + 0.00020029159932164476, + 0.00019931659917347134, + 0.00019979159987997263, + 0.0002010331998462789, + 0.00019940819911425933, + 0.0001998332008952275, + 0.00019949999987147748, + 0.0002043999993475154, + 0.00020044999982928857 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/SQRT/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/SQRT/tulipy]", + "params": { + "indicator": "SQRT", + "library": "tulipy" + }, + "param": "Math/SQRT/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019796659908024594, + "max": 0.00020641660084947945, + "mean": 0.00020077665991266258, + "stddev": 2.3446554471980215e-06, + "rounds": 20, + "median": 0.00020004160032840448, + "iqr": 2.3084998247213445e-06, + "q1": 0.00019934579977416434, + "q3": 0.0002016542995988857, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.00019796659908024594, + "hd15iqr": 0.0002060667990008369, + "ops": 4980.658610592476, + "total": 0.0040155331982532514, + "data": [ + 0.00020641660084947945, + 0.0002039915998466313, + 0.00019998320058220998, + 0.0002060667990008369, + 0.00020010000007459895, + 0.0001994083999306895, + 0.00019934159936383367, + 0.0002011917997151613, + 0.000199350000184495, + 0.0002002084002015181, + 0.00019880000036209822, + 0.00020034159970236943, + 0.0002027166003244929, + 0.00020211679948261007, + 0.0001989500000490807, + 0.00020030000014230608, + 0.0001997500003199093, + 0.00019796659908024594, + 0.00019912499992642553, + 0.00019940819911425933 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/LOG10/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/LOG10/ferro_ta]", + "params": { + "indicator": "LOG10", + "library": "ferro_ta" + }, + "param": "Math/LOG10/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004082500003278255, + "max": 0.0004459750009118579, + "mean": 0.00042151208006544036, + "stddev": 9.965537105237372e-06, + "rounds": 20, + "median": 0.00042056669990415686, + "iqr": 1.299170035053978e-05, + "q1": 0.0004145291997701861, + "q3": 0.00042752090012072587, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0004082500003278255, + "hd15iqr": 0.0004459750009118579, + "ops": 2372.411248201353, + "total": 0.008430241601308808, + "data": [ + 0.0004175832000328228, + 0.00043140819907421245, + 0.0004459750009118579, + 0.00043640000076266005, + 0.00042979179997928443, + 0.00041858339973259716, + 0.0004252500002621673, + 0.00042145000043092296, + 0.00041674160020193084, + 0.0004246666008839384, + 0.0004094165997230448, + 0.0004323500004829839, + 0.0004230417995131575, + 0.0004094165997230448, + 0.0004082500003278255, + 0.0004157250004936941, + 0.0004209334001643583, + 0.0004201999996439554, + 0.0004097249999176711, + 0.0004133333990466781 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/LOG10/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/LOG10/talib]", + "params": { + "indicator": "LOG10", + "library": "talib" + }, + "param": "Math/LOG10/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00039428319869330155, + "max": 0.0004127334002987482, + "mean": 0.0003995754099742044, + "stddev": 5.7883533169754076e-06, + "rounds": 20, + "median": 0.00039756250043865293, + "iqr": 3.266500425525053e-06, + "q1": 0.00039626259967917576, + "q3": 0.0003995291001047008, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.00039428319869330155, + "hd15iqr": 0.00041217499965569006, + "ops": 2502.656507477669, + "total": 0.007991508199484087, + "data": [ + 0.0003990249999333173, + 0.00039928320038598033, + 0.00039710840064799413, + 0.0003960667992942035, + 0.0003970499994466081, + 0.0003986166004324332, + 0.000396458400064148, + 0.0003957333989092149, + 0.00039648320089327174, + 0.0004124165992834605, + 0.00041217499965569006, + 0.0004127334002987482, + 0.0003990166005678475, + 0.00039801660022931173, + 0.00039586659986525776, + 0.0004003418012871407, + 0.0003945750009734184, + 0.0003964833987993188, + 0.00039428319869330155, + 0.00039977499982342125 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/LOG10/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/LOG10/tulipy]", + "params": { + "indicator": "LOG10", + "library": "tulipy" + }, + "param": "Math/LOG10/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00039564999897265805, + "max": 0.0004278166001313366, + "mean": 0.00040361790976021437, + "stddev": 8.844495047246635e-06, + "rounds": 20, + "median": 0.00040007499992498194, + "iqr": 1.0679200204322125e-05, + "q1": 0.00039707499963697045, + "q3": 0.0004077541998412926, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00039564999897265805, + "hd15iqr": 0.0004278166001313366, + "ops": 2477.5907506039325, + "total": 0.008072358195204288, + "data": [ + 0.00039697500033071266, + 0.0004123749997233972, + 0.0004185415993561037, + 0.0004278166001313366, + 0.0004021499989903532, + 0.0004034415993373841, + 0.00039564999897265805, + 0.0004062416002852842, + 0.0003988416006905027, + 0.000401150000107009, + 0.00039624999917577954, + 0.0003960749992984347, + 0.0003994833998149261, + 0.0004002083995146677, + 0.00039699159970041364, + 0.0003978666005423293, + 0.00040926679939730094, + 0.0004159333999268711, + 0.00039715839957352725, + 0.00039994160033529624 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/ADD/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/ADD/ferro_ta]", + "params": { + "indicator": "ADD", + "library": "ferro_ta" + }, + "param": "Math/ADD/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00017335820011794568, + "max": 0.00019092500006081536, + "mean": 0.0001815629100019578, + "stddev": 5.529575018618878e-06, + "rounds": 20, + "median": 0.00018430830023135057, + "iqr": 9.12910036277026e-06, + "q1": 0.00017660419980529695, + "q3": 0.0001857333001680672, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00017335820011794568, + "hd15iqr": 0.00019092500006081536, + "ops": 5507.732829294358, + "total": 0.003631258200039156, + "data": [ + 0.000177366599382367, + 0.0001774582007783465, + 0.00017509160097688435, + 0.00017642500024521722, + 0.00017550000047776847, + 0.0001772667994373478, + 0.00017433339962735772, + 0.00017335820011794568, + 0.0001841082004830241, + 0.00018654180021258072, + 0.0001848165993578732, + 0.000185608200263232, + 0.00018460839928593486, + 0.00018585840007290244, + 0.00019092500006081536, + 0.00017678339936537667, + 0.0001861500000813976, + 0.00018482500017853455, + 0.00018972499965457245, + 0.00018450839997967704 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/ADD/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/ADD/talib]", + "params": { + "indicator": "ADD", + "library": "talib" + }, + "param": "Math/ADD/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001908915990497917, + "max": 0.00021565820061368867, + "mean": 0.00020578290997946169, + "stddev": 7.142006035921883e-06, + "rounds": 20, + "median": 0.00020805840031243859, + "iqr": 9.524999768473208e-06, + "q1": 0.00020128329997533002, + "q3": 0.00021080829974380323, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0001908915990497917, + "hd15iqr": 0.00021565820061368867, + "ops": 4859.490032966322, + "total": 0.0041156581995892335, + "data": [ + 0.0001923834002809599, + 0.00020789999980479478, + 0.0002130581997334957, + 0.00021011659991927446, + 0.00020821680082008242, + 0.00020395000028656797, + 0.00020925839926348998, + 0.00020605840109055862, + 0.00020554179936880246, + 0.000199274999613408, + 0.00020257500000298023, + 0.000211499999568332, + 0.00021003339934395627, + 0.0002129416010575369, + 0.00021565820061368867, + 0.00020988320029573514, + 0.00019999159994767978, + 0.00019442500051809476, + 0.0001908915990497917, + 0.0002119999990100041 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/ADD/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/ADD/tulipy]", + "params": { + "indicator": "ADD", + "library": "tulipy" + }, + "param": "Math/ADD/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001863249999587424, + "max": 0.00020114159997319803, + "mean": 0.00019235584994021338, + "stddev": 5.219129504543149e-06, + "rounds": 20, + "median": 0.00019120839933748358, + "iqr": 1.0241800191579365e-05, + "q1": 0.00018709159994614313, + "q3": 0.0001973334001377225, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.0001863249999587424, + "hd15iqr": 0.00020114159997319803, + "ops": 5198.698143627099, + "total": 0.0038471169988042674, + "data": [ + 0.00019796660053543745, + 0.0001915834000101313, + 0.0001882918004412204, + 0.0001870416002930142, + 0.00019564179965527728, + 0.0001968750002561137, + 0.00019335840042913332, + 0.0001999833999434486, + 0.0001936000000569038, + 0.0001866917998995632, + 0.00018672500009415672, + 0.00019083339866483585, + 0.00020019999938085674, + 0.00018873319932026788, + 0.00018714159959927202, + 0.0001863249999587424, + 0.00018642500072019175, + 0.00019779180001933128, + 0.00020114159997319803, + 0.00019076659955317155 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG/ferro_ta]", + "params": { + "indicator": "LINEARREG", + "library": "ferro_ta" + }, + "param": "Statistics/LINEARREG/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00145559999946272, + "max": 0.001523283199639991, + "mean": 0.0014820245298324153, + "stddev": 1.8366629732714396e-05, + "rounds": 20, + "median": 0.0014822582998021971, + "iqr": 2.8616600320674652e-05, + "q1": 0.0014653582999017089, + "q3": 0.0014939749002223835, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00145559999946272, + "hd15iqr": 0.001523283199639991, + "ops": 674.752664257911, + "total": 0.029640490596648306, + "data": [ + 0.0014880581991747021, + 0.0014979415995185264, + 0.0014665499998955055, + 0.0014641665999079122, + 0.0014938831998733803, + 0.0014937165993615053, + 0.0014734000011230818, + 0.0014848415987216868, + 0.0014620499990996906, + 0.001485933200456202, + 0.0014566833997378126, + 0.0014628665987402201, + 0.001476666599046439, + 0.00151004159997683, + 0.00145559999946272, + 0.001523283199639991, + 0.0014715916011482477, + 0.001499475000309758, + 0.0014940666005713865, + 0.0014796750008827075 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG/talib]", + "params": { + "indicator": "LINEARREG", + "library": "talib" + }, + "param": "Statistics/LINEARREG/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006610750002437271, + "max": 0.0006917499995324761, + "mean": 0.000674682919998304, + "stddev": 8.57603031776785e-06, + "rounds": 20, + "median": 0.0006715875002555548, + "iqr": 9.525099449092544e-06, + "q1": 0.0006698124001559336, + "q3": 0.0006793374996050261, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0006610750002437271, + "hd15iqr": 0.0006917499995324761, + "ops": 1482.1777317299122, + "total": 0.01349365839996608, + "data": [ + 0.0006853500002762303, + 0.000690125000255648, + 0.000672241800930351, + 0.0006717000011121854, + 0.0006733249989338219, + 0.0006702668004436418, + 0.0006716666001011617, + 0.0006725582003127784, + 0.0006917499995324761, + 0.0006699999998090789, + 0.0006698331999359652, + 0.0006697916003759019, + 0.0006715084004099481, + 0.0006685999993351288, + 0.0006699749996187165, + 0.00066967499878956, + 0.0006693749994155951, + 0.000688541799900122, + 0.0006610750002437271, + 0.0006863000002340413 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG/tulipy]", + "params": { + "indicator": "LINEARREG", + "library": "tulipy" + }, + "param": "Statistics/LINEARREG/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00033202500053448604, + "max": 0.0004885332004050724, + "mean": 0.0003456670999730704, + "stddev": 3.624581809021745e-05, + "rounds": 20, + "median": 0.000333616700663697, + "iqr": 3.6750003346242227e-06, + "q1": 0.00033264999947277827, + "q3": 0.0003363249998074025, + "iqr_outliers": 4, + "stddev_outliers": 2, + "outliers": "2;4", + "ld15iqr": 0.00033202500053448604, + "hd15iqr": 0.0003443833993515, + "ops": 2892.9568364414954, + "total": 0.0069133419994614085, + "data": [ + 0.00033325839904136957, + 0.00033375000057276336, + 0.0003327083992189728, + 0.0003320668009109795, + 0.0003365750002558343, + 0.000334324999130331, + 0.00033202500053448604, + 0.0003325915997265838, + 0.0003330250008730218, + 0.00033384179987479, + 0.0003443833993515, + 0.00035099160013487565, + 0.00033252499997615814, + 0.0003324749995954335, + 0.00033538340067025275, + 0.0003334834007546306, + 0.00033322499948553743, + 0.0003360749993589707, + 0.00039209999958984555, + 0.0004885332004050724 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG_SLOPE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG_SLOPE/ferro_ta]", + "params": { + "indicator": "LINEARREG_SLOPE", + "library": "ferro_ta" + }, + "param": "Statistics/LINEARREG_SLOPE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0013671418011654169, + "max": 0.0017049165995558723, + "mean": 0.0014223262300947681, + "stddev": 7.078392021025672e-05, + "rounds": 20, + "median": 0.0014047750002646351, + "iqr": 2.2579100914299488e-05, + "q1": 0.0013929707994975616, + "q3": 0.001415549900411861, + "iqr_outliers": 3, + "stddev_outliers": 1, + "outliers": "1;3", + "ld15iqr": 0.0013671418011654169, + "hd15iqr": 0.0014621833994169719, + "ops": 703.0735838523987, + "total": 0.02844652460189536, + "data": [ + 0.0014043750008568168, + 0.0013671418011654169, + 0.0013705249992199242, + 0.00141436660051113, + 0.0017049165995558723, + 0.0014621833994169719, + 0.0014074083999730646, + 0.001392275000398513, + 0.0013926749990787358, + 0.0013932665999163874, + 0.001398941600928083, + 0.0014154832009808161, + 0.0014049916004296391, + 0.0014089749995036982, + 0.0013907665997976437, + 0.0014035332002094946, + 0.001466775000153575, + 0.001427749999857042, + 0.001415616599842906, + 0.0014045584000996314 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG_SLOPE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG_SLOPE/talib]", + "params": { + "indicator": "LINEARREG_SLOPE", + "library": "talib" + }, + "param": "Statistics/LINEARREG_SLOPE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006064916000468656, + "max": 0.0006430918001569808, + "mean": 0.0006272379300207831, + "stddev": 8.621342682502298e-06, + "rounds": 20, + "median": 0.0006251624996366444, + "iqr": 9.795799996936694e-06, + "q1": 0.0006221625008038245, + "q3": 0.0006319583008007612, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0006211917992914095, + "hd15iqr": 0.0006430918001569808, + "ops": 1594.2913400770674, + "total": 0.012544758600415661, + "data": [ + 0.0006425083993235603, + 0.0006398916011676192, + 0.0006225749995792285, + 0.0006211917992914095, + 0.0006430918001569808, + 0.0006064916000468656, + 0.0006218333990545943, + 0.0006296666004345752, + 0.0006228418002137915, + 0.00062704159936402, + 0.000632275000680238, + 0.000634258400532417, + 0.0006241499999305233, + 0.0006224416007171385, + 0.0006220000010216609, + 0.0006215499990503304, + 0.0006223250005859881, + 0.0006261749993427656, + 0.0006308083990006708, + 0.0006316416009212844 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG_SLOPE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG_SLOPE/tulipy]", + "params": { + "indicator": "LINEARREG_SLOPE", + "library": "tulipy" + }, + "param": "Statistics/LINEARREG_SLOPE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00033069160126615317, + "max": 0.0003595166010200046, + "mean": 0.0003377208101301221, + "stddev": 7.56983484703236e-06, + "rounds": 20, + "median": 0.00033351669990224766, + "iqr": 1.0154199844691913e-05, + "q1": 0.00033238739997614174, + "q3": 0.00034254159982083365, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00033069160126615317, + "hd15iqr": 0.0003595166010200046, + "ops": 2961.025705270294, + "total": 0.006754416202602443, + "data": [ + 0.0003419165994273499, + 0.0003472750002401881, + 0.0003388249999261461, + 0.00034316660021431743, + 0.00033338339999318124, + 0.00033292500011157246, + 0.0003351666004164144, + 0.000333649999811314, + 0.00033265819947700945, + 0.000332116600475274, + 0.00033202500053448604, + 0.0003328916005557403, + 0.00033117499988293274, + 0.00033153340045828373, + 0.0003407333992072381, + 0.0003477499994914979, + 0.00034374160022707654, + 0.00033069160126615317, + 0.0003595166010200046, + 0.0003332749998662621 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/CORREL/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/CORREL/ferro_ta]", + "params": { + "indicator": "CORREL", + "library": "ferro_ta" + }, + "param": "Statistics/CORREL/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0038626666006166487, + "max": 0.004232891600986477, + "mean": 0.00391570332023548, + "stddev": 8.747625483016705e-05, + "rounds": 20, + "median": 0.003883141699770931, + "iqr": 4.847509990213439e-05, + "q1": 0.003865191600198159, + "q3": 0.003913666700100293, + "iqr_outliers": 4, + "stddev_outliers": 2, + "outliers": "2;4", + "ld15iqr": 0.0038626666006166487, + "hd15iqr": 0.003992983199714218, + "ops": 255.38196288575378, + "total": 0.07831406640470959, + "data": [ + 0.003921508400526364, + 0.0038778582005761565, + 0.0038811167993117123, + 0.0038626666006166487, + 0.0038707665997208098, + 0.0038973084010649472, + 0.00388516660023015, + 0.0038654999996651897, + 0.0038648250003461724, + 0.0038721418008208276, + 0.003905824999674223, + 0.0038648832007311283, + 0.0038637916004518047, + 0.0039050584004144185, + 0.004232891600986477, + 0.00400355000019772, + 0.003992983199714218, + 0.0038626749999821188, + 0.003994975000387058, + 0.0038885749992914496 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/CORREL/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/CORREL/talib]", + "params": { + "indicator": "CORREL", + "library": "talib" + }, + "param": "Statistics/CORREL/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003729000003659166, + "max": 0.00041951660095946864, + "mean": 0.0003939791502489243, + "stddev": 1.1537300668324215e-05, + "rounds": 20, + "median": 0.00039747499977238476, + "iqr": 1.2825000158045452e-05, + "q1": 0.0003884625002683606, + "q3": 0.00040128750042640605, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0003729000003659166, + "hd15iqr": 0.00041951660095946864, + "ops": 2538.2053831228864, + "total": 0.007879583004978485, + "data": [ + 0.00040120000048773365, + 0.000374716600344982, + 0.00040137500036507845, + 0.00040050820098258555, + 0.000392699999792967, + 0.00037810000067111104, + 0.00040027499926509336, + 0.00040147499967133624, + 0.00039029999898048117, + 0.0003891250002197921, + 0.0003951749997213483, + 0.00041951660095946864, + 0.0004015166006865911, + 0.0003772834010305814, + 0.0003878000003169291, + 0.00039977499982342125, + 0.00040002500027185305, + 0.0003729000003659166, + 0.0003929500002413988, + 0.0004028666007798165 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/BETA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/BETA/ferro_ta]", + "params": { + "indicator": "BETA", + "library": "ferro_ta" + }, + "param": "Statistics/BETA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.003995058400323615, + "max": 0.004397025000071153, + "mean": 0.00408184665015142, + "stddev": 8.605343629080731e-05, + "rounds": 20, + "median": 0.004060154200124089, + "iqr": 3.662080052890815e-05, + "q1": 0.004046108300099149, + "q3": 0.004082729100628057, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.003995058400323615, + "hd15iqr": 0.00413845000002766, + "ops": 244.98715549808912, + "total": 0.08163693300302839, + "data": [ + 0.004045324999606237, + 0.004063358400890138, + 0.004075075000582728, + 0.004061349999392405, + 0.004057299999112729, + 0.004058958400855772, + 0.004046891600592062, + 0.004090383200673386, + 0.004074033399228938, + 0.004049316600139718, + 0.004047991600236856, + 0.004190500000549946, + 0.004397025000071153, + 0.0040616166006657295, + 0.004010858399851713, + 0.00413845000002766, + 0.0041143915994325654, + 0.003995058400323615, + 0.0040274582002894025, + 0.004031591600505635 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/BETA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/BETA/talib]", + "params": { + "indicator": "BETA", + "library": "talib" + }, + "param": "Statistics/BETA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004678833996877074, + "max": 0.0005030665997765027, + "mean": 0.00047983289005060215, + "stddev": 8.751089658950115e-06, + "rounds": 20, + "median": 0.00047663750010542574, + "iqr": 1.3012500130571414e-05, + "q1": 0.00047343750047730283, + "q3": 0.00048645000060787424, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0004678833996877074, + "hd15iqr": 0.0005030665997765027, + "ops": 2084.0588895324413, + "total": 0.009596657801012043, + "data": [ + 0.0004861000008531846, + 0.0004733331996249035, + 0.00048185000050580127, + 0.00047655840025981886, + 0.0004904415996861644, + 0.0005030665997765027, + 0.0004868000003625639, + 0.0004754915993544273, + 0.00048053320060716944, + 0.0004800331997103058, + 0.00047340000019175933, + 0.00047487499978160486, + 0.0004726332001155242, + 0.0004695334006100893, + 0.00047671659995103256, + 0.0004678833996877074, + 0.00047347500076284633, + 0.00048822499957168475, + 0.0004910083996946923, + 0.00047469999990426006 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Cycle/HT_DCPERIOD/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Cycle/HT_DCPERIOD/ferro_ta]", + "params": { + "indicator": "HT_DCPERIOD", + "library": "ferro_ta" + }, + "param": "Cycle/HT_DCPERIOD/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.007323950000863988, + "max": 0.009919116599485278, + "mean": 0.008005460389831569, + "stddev": 0.0006926875628735123, + "rounds": 20, + "median": 0.007776487499359063, + "iqr": 0.000840895799046849, + "q1": 0.007455120800295844, + "q3": 0.008296016599342693, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.007323950000863988, + "hd15iqr": 0.009919116599485278, + "ops": 124.91473960325716, + "total": 0.16010920779663138, + "data": [ + 0.0077805415989132595, + 0.007755433199054096, + 0.007772433399804868, + 0.007765550000476651, + 0.007814133400097489, + 0.007323950000863988, + 0.0073357916000531985, + 0.0073465415989630856, + 0.009468150000611786, + 0.009919116599485278, + 0.007482650000019931, + 0.007427591600571759, + 0.007363516600162256, + 0.0076766999991377816, + 0.00829995819949545, + 0.008374058399931527, + 0.008253083199087996, + 0.00837080839992268, + 0.008292074999189936, + 0.008287125000788365 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Cycle/HT_DCPERIOD/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Cycle/HT_DCPERIOD/talib]", + "params": { + "indicator": "HT_DCPERIOD", + "library": "talib" + }, + "param": "Cycle/HT_DCPERIOD/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.003796600000350736, + "max": 0.00395624160009902, + "mean": 0.0038471208400005707, + "stddev": 3.878078225296844e-05, + "rounds": 20, + "median": 0.003833662500255741, + "iqr": 5.0362599722575396e-05, + "q1": 0.0038217124005313964, + "q3": 0.0038720750002539718, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.003796600000350736, + "hd15iqr": 0.00395624160009902, + "ops": 259.93464764674553, + "total": 0.07694241680001142, + "data": [ + 0.00381035000027623, + 0.0038889666000613944, + 0.003835241599881556, + 0.003829374999622814, + 0.0038867665993166157, + 0.0038201415998628365, + 0.003896633398835547, + 0.0038232832011999562, + 0.003796600000350736, + 0.0038469167993753217, + 0.003830466800718568, + 0.0038635000004433096, + 0.003859250000095926, + 0.003806791799433995, + 0.003827900000032969, + 0.003837333399860654, + 0.003880650000064634, + 0.0038320834006299264, + 0.0038139249998494053, + 0.00395624160009902 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Cycle/HT_TRENDMODE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Cycle/HT_TRENDMODE/ferro_ta]", + "params": { + "indicator": "HT_TRENDMODE", + "library": "ferro_ta" + }, + "param": "Cycle/HT_TRENDMODE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.007960100000491365, + "max": 0.011040958399826195, + "mean": 0.010046701689861947, + "stddev": 0.0010392377507822718, + "rounds": 20, + "median": 0.010464400099590421, + "iqr": 0.0013997708992974367, + "q1": 0.009448516699922038, + "q3": 0.010848287599219474, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.007960100000491365, + "hd15iqr": 0.011040958399826195, + "ops": 99.5351540107031, + "total": 0.20093403379723895, + "data": [ + 0.010260333398764487, + 0.010111258398683275, + 0.010663758400187361, + 0.010610716600785964, + 0.0096265084008337, + 0.01046013339946512, + 0.010258308199991007, + 0.009270524999010377, + 0.010789841799123678, + 0.01046866679971572, + 0.010965533398848492, + 0.010908058199856897, + 0.011040958399826195, + 0.008618766801373568, + 0.007966333199874498, + 0.007960100000491365, + 0.008388983200711663, + 0.01096806679997826, + 0.010690450000402052, + 0.010906733399315272 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Cycle/HT_TRENDMODE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Cycle/HT_TRENDMODE/talib]", + "params": { + "indicator": "HT_TRENDMODE", + "library": "talib" + }, + "param": "Cycle/HT_TRENDMODE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.021779850000166336, + "max": 0.02306985819886904, + "mean": 0.02224127873996622, + "stddev": 0.0002754805453360799, + "rounds": 20, + "median": 0.022193179199530275, + "iqr": 0.0001630750011827331, + "q1": 0.0221005832994706, + "q3": 0.022263658300653334, + "iqr_outliers": 4, + "stddev_outliers": 4, + "outliers": "4;4", + "ld15iqr": 0.022014825000951532, + "hd15iqr": 0.022551658199517988, + "ops": 44.961443615337686, + "total": 0.4448255747993244, + "data": [ + 0.022721391799859703, + 0.022551658199517988, + 0.02232100000110222, + 0.022269458400842268, + 0.022181408399774227, + 0.022194633400067686, + 0.022152916599588936, + 0.022014825000951532, + 0.022028575000877026, + 0.022016858399729243, + 0.0220776999994996, + 0.022219908398983534, + 0.022178116599388887, + 0.022191724998992867, + 0.022245625000505243, + 0.0221234665994416, + 0.021779850000166336, + 0.022257858200464397, + 0.02222874160070205, + 0.02306985819886904 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLENGULFING/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLENGULFING/ferro_ta]", + "params": { + "indicator": "CDLENGULFING", + "library": "ferro_ta" + }, + "param": "Pattern/CDLENGULFING/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00027645840018521997, + "max": 0.00044519160001073034, + "mean": 0.00031902997019642496, + "stddev": 4.123335110700092e-05, + "rounds": 20, + "median": 0.000308274900453398, + "iqr": 4.951250011799854e-05, + "q1": 0.0002861957997083664, + "q3": 0.00033570829982636495, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.00027645840018521997, + "hd15iqr": 0.00044519160001073034, + "ops": 3134.5017503662916, + "total": 0.0063805994039285, + "data": [ + 0.00034315000084461644, + 0.00035074160114163534, + 0.00030078340059844775, + 0.0002919749997090548, + 0.00044519160001073034, + 0.00033306659897789357, + 0.0003290831999038346, + 0.00030839160026516763, + 0.0002831416000844911, + 0.0003003834004630335, + 0.00037868320068810133, + 0.00033835000067483634, + 0.00033140820014523344, + 0.00028666659927694126, + 0.00028000000020256266, + 0.00030815820064162837, + 0.00033269999985350294, + 0.0002857250001397915, + 0.00027645840018521997, + 0.00027654180012177677 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLENGULFING/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLENGULFING/talib]", + "params": { + "indicator": "CDLENGULFING", + "library": "talib" + }, + "param": "Pattern/CDLENGULFING/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005443168003694155, + "max": 0.0007677750007132999, + "mean": 0.0006501204300730024, + "stddev": 6.981187568004996e-05, + "rounds": 20, + "median": 0.0006347791997541208, + "iqr": 9.977910012821673e-05, + "q1": 0.0005985791998682543, + "q3": 0.0006983582999964711, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005443168003694155, + "hd15iqr": 0.0007677750007132999, + "ops": 1538.1765496705118, + "total": 0.013002408601460047, + "data": [ + 0.0006777583999792114, + 0.0007677750007132999, + 0.0006669082009466365, + 0.0007590334003907629, + 0.0006067334004910662, + 0.0006003083995892666, + 0.0007622000004630536, + 0.000690349999058526, + 0.0007063666009344161, + 0.0005775331999757327, + 0.000599766599771101, + 0.0006019499996909872, + 0.0006586834002519027, + 0.0007503834000090137, + 0.0006416999996872619, + 0.0005973917999654077, + 0.0005713331993320026, + 0.0005443168003694155, + 0.0005940584000200033, + 0.0006278583998209797 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLDOJI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLDOJI/ferro_ta]", + "params": { + "indicator": "CDLDOJI", + "library": "ferro_ta" + }, + "param": "Pattern/CDLDOJI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00023774160072207451, + "max": 0.00047185819857986644, + "mean": 0.0003055166601552628, + "stddev": 7.150500420973441e-05, + "rounds": 20, + "median": 0.0002803500996378716, + "iqr": 9.031669978867287e-05, + "q1": 0.00025425410058232956, + "q3": 0.00034457080037100243, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.00023774160072207451, + "hd15iqr": 0.00047185819857986644, + "ops": 3273.143924432149, + "total": 0.006110333203105256, + "data": [ + 0.0004619667990482412, + 0.00030373340123333035, + 0.00037227499997243284, + 0.000348658200528007, + 0.000283708400093019, + 0.0002506000004359521, + 0.00034048340021399783, + 0.00025790820072870704, + 0.00039388320001307873, + 0.0003045083998586051, + 0.00047185819857986644, + 0.0002621834006276913, + 0.0002602332009701058, + 0.00029782499914290386, + 0.0002662584010977298, + 0.0002426916005788371, + 0.00023831660073483362, + 0.00023774160072207451, + 0.00023850839934311808, + 0.0002769917991827242 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLDOJI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLDOJI/talib]", + "params": { + "indicator": "CDLDOJI", + "library": "talib" + }, + "param": "Pattern/CDLDOJI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002851249999366701, + "max": 0.0004393415991216898, + "mean": 0.000338947489799466, + "stddev": 4.36485691107468e-05, + "rounds": 20, + "median": 0.00032393339934060354, + "iqr": 6.102920015109707e-05, + "q1": 0.0003061165996768977, + "q3": 0.0003671457998279948, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0002851249999366701, + "hd15iqr": 0.0004393415991216898, + "ops": 2950.3095024885342, + "total": 0.00677894979598932, + "data": [ + 0.00031802500016056003, + 0.0003555750008672476, + 0.0002993749993038364, + 0.0003161168002407067, + 0.00041346660000272093, + 0.00032746679935371505, + 0.00036507500044535846, + 0.0004393415991216898, + 0.00031182499951682986, + 0.0003739166000741534, + 0.0003097166001680307, + 0.00034492499980842695, + 0.000320399999327492, + 0.0002861916000256315, + 0.00030251659918576477, + 0.0003692165992106311, + 0.0003476083991699852, + 0.0003996583996922709, + 0.0002934082003775984, + 0.0002851249999366701 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLHAMMER/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLHAMMER/ferro_ta]", + "params": { + "indicator": "CDLHAMMER", + "library": "ferro_ta" + }, + "param": "Pattern/CDLHAMMER/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002776084002107382, + "max": 0.0005028999992646277, + "mean": 0.0003348600000026636, + "stddev": 5.698589436247308e-05, + "rounds": 20, + "median": 0.0003203500004019588, + "iqr": 6.823329968028705e-05, + "q1": 0.0002885458001401275, + "q3": 0.00035677909982041457, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0002776084002107382, + "hd15iqr": 0.0005028999992646277, + "ops": 2986.3226422745197, + "total": 0.006697200000053272, + "data": [ + 0.00030877500103088096, + 0.000311800000781659, + 0.00040400839934591205, + 0.0003831249996437691, + 0.0004137249998166226, + 0.0002900416002376005, + 0.00036117499985266477, + 0.00033629179961280897, + 0.00030974999972386283, + 0.0002828249998856336, + 0.0002796250002575107, + 0.0002776084002107382, + 0.00035124160058330745, + 0.0005028999992646277, + 0.00030152499966789036, + 0.00028470000106608497, + 0.0002870500000426546, + 0.00032974999921862034, + 0.0003289000000222586, + 0.00035238319978816437 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLHAMMER/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLHAMMER/talib]", + "params": { + "indicator": "CDLHAMMER", + "library": "talib" + }, + "param": "Pattern/CDLHAMMER/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012501666002208366, + "max": 0.0015625581989297643, + "mean": 0.0014000166499317857, + "stddev": 0.00010361022991808505, + "rounds": 20, + "median": 0.001375045900203986, + "iqr": 0.00018455009994795563, + "q1": 0.0013162623996322508, + "q3": 0.0015008124995802064, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0012501666002208366, + "hd15iqr": 0.0015625581989297643, + "ops": 714.2772195235849, + "total": 0.02800033299863571, + "data": [ + 0.0015434418004588225, + 0.0013960582000436261, + 0.001456891599809751, + 0.0013163915995392018, + 0.00139260839932831, + 0.0013214832011726684, + 0.0015625581989297643, + 0.0014761665996047668, + 0.0013477583997882903, + 0.0012501666002208366, + 0.0015336333992308937, + 0.0014753666007891297, + 0.0015254583995556459, + 0.001314125000499189, + 0.0013161331997253, + 0.0015333916002418847, + 0.0013304833992151543, + 0.0012883417992270551, + 0.0013574834010796621, + 0.0012623916001757607 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[SMA-libs0]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[SMA-libs0]", + "params": { + "indicator": "SMA", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "SMA-libs0", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00022535840107593686, + "max": 0.0003908915998181328, + "mean": 0.00026595085982989987, + "stddev": 4.823923582782572e-05, + "rounds": 20, + "median": 0.0002444042002025526, + "iqr": 4.6020900481380566e-05, + "q1": 0.0002359374993829988, + "q3": 0.00028195839986437936, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.00022535840107593686, + "hd15iqr": 0.0003756834004889242, + "ops": 3760.0931263752723, + "total": 0.005319017196597997, + "data": [ + 0.00023258339933818206, + 0.0003756834004889242, + 0.0002793749998090789, + 0.0002845417999196798, + 0.00024655839952174573, + 0.00029242499877000225, + 0.00024214180011767895, + 0.00025910840049618854, + 0.00022889999963808804, + 0.00022564160026377066, + 0.00022535840107593686, + 0.00033146679925266653, + 0.00023929159942781553, + 0.00026312499976484106, + 0.00024225000088335947, + 0.0002418249991023913, + 0.00023943340056575835, + 0.00022699159890180454, + 0.0002514249994419515, + 0.0003908915998181328 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[EMA-libs1]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[EMA-libs1]", + "params": { + "indicator": "EMA", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "EMA-libs1", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003487082009087317, + "max": 0.0005188084003748372, + "mean": 0.0004303175001405179, + "stddev": 6.0679005820778565e-05, + "rounds": 20, + "median": 0.00042030409967992456, + "iqr": 0.00011644580008578491, + "q1": 0.0003814707997662481, + "q3": 0.000497916599852033, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.0003487082009087317, + "hd15iqr": 0.0005188084003748372, + "ops": 2323.865517143633, + "total": 0.008606350002810358, + "data": [ + 0.0004217249996145256, + 0.0003828583998256363, + 0.0003824333994998597, + 0.00043631680018734186, + 0.0003891250002197921, + 0.0005177166007342748, + 0.00040672500035725533, + 0.0003487082009087317, + 0.00045739159977529196, + 0.0004188831997453235, + 0.0005148415992152877, + 0.0003805082000326365, + 0.0004980500001693144, + 0.0005188084003748372, + 0.0003603668010327965, + 0.0003534168004989624, + 0.0004977831995347515, + 0.0005111000005854294, + 0.00036835840001003817, + 0.0004412334004882723 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[RSI-libs2]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[RSI-libs2]", + "params": { + "indicator": "RSI", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "RSI-libs2", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006004581999150104, + "max": 0.000859291800588835, + "mean": 0.0007025346101727336, + "stddev": 6.856376123163572e-05, + "rounds": 20, + "median": 0.0007091459003277123, + "iqr": 7.977930072229362e-05, + "q1": 0.0006539041001815348, + "q3": 0.0007336834009038284, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0006004581999150104, + "hd15iqr": 0.000859291800588835, + "ops": 1423.4174167648878, + "total": 0.014050692203454673, + "data": [ + 0.000859291800588835, + 0.0007221749998279847, + 0.0008353331999387592, + 0.0006742249999660999, + 0.000654758200107608, + 0.0007119666013750247, + 0.0007226750007248483, + 0.0007078167996951379, + 0.0007104750009602867, + 0.0006370834002154879, + 0.0006004581999150104, + 0.0007521334002376534, + 0.0007446918010828085, + 0.0007562333994428627, + 0.0007003417995292693, + 0.0006153084003017284, + 0.0006530500002554617, + 0.0007192333985585719, + 0.000670091800566297, + 0.0006033500001649372 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[MACD-libs3]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[MACD-libs3]", + "params": { + "indicator": "MACD", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "MACD-libs3", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005871916000614874, + "max": 0.0007956000001286157, + "mean": 0.0006621316400560318, + "stddev": 5.93817845656767e-05, + "rounds": 20, + "median": 0.0006457832998421509, + "iqr": 9.63751008384861e-05, + "q1": 0.0006131748996267561, + "q3": 0.0007095500004652422, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005871916000614874, + "hd15iqr": 0.0007956000001286157, + "ops": 1510.273697108594, + "total": 0.013242632801120636, + "data": [ + 0.0007069416009471752, + 0.0006307831994490698, + 0.0005884418002096936, + 0.0005928583996137604, + 0.000744291600130964, + 0.0005976416010526009, + 0.0007121583999833092, + 0.0007316332004847937, + 0.0006800999995903112, + 0.0006217331989319064, + 0.0006911415999638848, + 0.0006528500001877546, + 0.0006321833992842585, + 0.0007195915997726843, + 0.0006334418008918874, + 0.0005871916000614874, + 0.0007956000001286157, + 0.0006807166006183252, + 0.0006046166003216058, + 0.0006387165994965471 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[BBANDS-libs4]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[BBANDS-libs4]", + "params": { + "indicator": "BBANDS", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "BBANDS-libs4", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003209165995940566, + "max": 0.00045758339983876796, + "mean": 0.0003743137299170485, + "stddev": 4.3629488101413486e-05, + "rounds": 20, + "median": 0.0003590501000871882, + "iqr": 6.953340052859861e-05, + "q1": 0.000340087399672484, + "q3": 0.0004096208002010826, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0003209165995940566, + "hd15iqr": 0.00045758339983876796, + "ops": 2671.5557567754986, + "total": 0.00748627459834097, + "data": [ + 0.0003495249999104999, + 0.00034434999979566784, + 0.00045758339983876796, + 0.0004060666004079394, + 0.0003684333991259336, + 0.00034643340040929615, + 0.0003219416001229547, + 0.0004367665998870507, + 0.0004034083991427906, + 0.0003442082001129165, + 0.0004131749999942258, + 0.00033175819989992303, + 0.00038564999995287507, + 0.0003359665992320515, + 0.0004446332008228637, + 0.0003266500003519468, + 0.0003209165995940566, + 0.00034966680104844274, + 0.0004173665991402231, + 0.0003817749995505437 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[ATR-libs5]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[ATR-libs5]", + "params": { + "indicator": "ATR", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "ATR-libs5", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005948249992798083, + "max": 0.000859641600982286, + "mean": 0.0007126333399355645, + "stddev": 8.618998369969726e-05, + "rounds": 20, + "median": 0.0006980707003094722, + "iqr": 0.00016346669945050958, + "q1": 0.0006284541996137705, + "q3": 0.00079192089906428, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.0005948249992798083, + "hd15iqr": 0.000859641600982286, + "ops": 1403.246163153718, + "total": 0.014252666798711289, + "data": [ + 0.0005948249992798083, + 0.0006146500003524124, + 0.0006242418006877415, + 0.0007484668007236905, + 0.0006880832006572746, + 0.0006186750004417263, + 0.0008385833993088454, + 0.0007426833995850757, + 0.0007771499993395991, + 0.0008208833998651244, + 0.0006248083998798392, + 0.000859641600982286, + 0.0006499249997432343, + 0.0008153081987984478, + 0.0006320999993477017, + 0.000806691798788961, + 0.0006608416006201878, + 0.0007080581999616697, + 0.0007617665993166156, + 0.0006652834010310471 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[CCI-libs6]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[CCI-libs6]", + "params": { + "indicator": "CCI", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "CCI-libs6", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008333416000823491, + "max": 0.0011288665991742164, + "mean": 0.0009782278901548124, + "stddev": 8.724398235268927e-05, + "rounds": 20, + "median": 0.0009738291999383363, + "iqr": 0.00014463750048889792, + "q1": 0.0009124541000346653, + "q3": 0.0010570916005235632, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0008333416000823491, + "hd15iqr": 0.0011288665991742164, + "ops": 1022.2566848321427, + "total": 0.01956455780309625, + "data": [ + 0.0008689332011272199, + 0.0011221915992791764, + 0.0009622666009818203, + 0.0010609250006382354, + 0.0010683168002287857, + 0.0008855332009261474, + 0.0011288665991742164, + 0.001002225000411272, + 0.0009375581998028792, + 0.0008873500002664514, + 0.000942708199727349, + 0.001067049999255687, + 0.0010165833999053575, + 0.0009752500001923182, + 0.0009474667996983044, + 0.0008489750005537644, + 0.0009724083996843546, + 0.0008333416000823491, + 0.001053258200408891, + 0.0009833500007516704 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[WILLR-libs7]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[WILLR-libs7]", + "params": { + "indicator": "WILLR", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "WILLR-libs7", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012216834002174437, + "max": 0.0017021584004396572, + "mean": 0.0013860970802488737, + "stddev": 0.0001148429215671508, + "rounds": 20, + "median": 0.0013733209008933045, + "iqr": 0.00014456269927904958, + "q1": 0.0013223582005593925, + "q3": 0.001466920899838442, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0012216834002174437, + "hd15iqr": 0.0017021584004396572, + "ops": 721.4501886263623, + "total": 0.027721941604977474, + "data": [ + 0.0014786749990889803, + 0.0017021584004396572, + 0.0014758750010514631, + 0.0013179832007153892, + 0.001525325000693556, + 0.0014134415992884896, + 0.0013376750008319504, + 0.0013269750008475967, + 0.0012216834002174437, + 0.001326733200403396, + 0.001361883401114028, + 0.0012386831993353553, + 0.001405541600252036, + 0.0014663334004580975, + 0.0014675083992187865, + 0.001384758400672581, + 0.001283883399446495, + 0.0012291750012082049, + 0.0014100915999733844, + 0.001347558399720583 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[OBV-libs8]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[OBV-libs8]", + "params": { + "indicator": "OBV", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "OBV-libs8", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004056831996422261, + "max": 0.0006892416000482626, + "mean": 0.0005023220600560307, + "stddev": 8.58000897815503e-05, + "rounds": 20, + "median": 0.00047118750007939524, + "iqr": 0.000138708199665416, + "q1": 0.0004315042002417613, + "q3": 0.0005702123999071773, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0004056831996422261, + "hd15iqr": 0.0006892416000482626, + "ops": 1990.7546960777645, + "total": 0.010046441201120615, + "data": [ + 0.0006315749997156672, + 0.0005604831996606663, + 0.00048284179938491435, + 0.0005114750005304813, + 0.0006213333996129222, + 0.0004485750003368594, + 0.00042817500070668757, + 0.0005696666004951112, + 0.000434833399776835, + 0.0004056831996422261, + 0.00047114999906625597, + 0.0006892416000482626, + 0.0004401166006573476, + 0.000434941599087324, + 0.00042725820094347, + 0.00041915839974535627, + 0.0005707581993192434, + 0.0004712250010925345, + 0.0006021416003932246, + 0.0004258084009052254 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[ADX-libs9]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[ADX-libs9]", + "params": { + "indicator": "ADX", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "ADX-libs9", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007454249993315898, + "max": 0.001059275001171045, + "mean": 0.0008934458199655637, + "stddev": 8.756377436001077e-05, + "rounds": 20, + "median": 0.0008820791998005006, + "iqr": 9.906669947667979e-05, + "q1": 0.0008408416004385799, + "q3": 0.0009399082999152597, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0007454249993315898, + "hd15iqr": 0.001059275001171045, + "ops": 1119.2620499791954, + "total": 0.017868916399311274, + "data": [ + 0.0008991665992652998, + 0.0007454249993315898, + 0.00081320820027031, + 0.0008374666009331122, + 0.0008442165999440476, + 0.0008534417996997945, + 0.001049333199625835, + 0.0009284500003559515, + 0.0009965499993995763, + 0.0009513665994745679, + 0.0008534499997040257, + 0.001059275001171045, + 0.000770541800011415, + 0.0008853584004100412, + 0.000898391600640025, + 0.0007994250001502224, + 0.000910450000083074, + 0.0010221083997748793, + 0.0008787999991909601, + 0.0008724915998755023 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[MFI-libs10]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[MFI-libs10]", + "params": { + "indicator": "MFI", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "MFI-libs10", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003189583992934786, + "max": 0.000509766599861905, + "mean": 0.0003949437400297029, + "stddev": 6.526435858910924e-05, + "rounds": 20, + "median": 0.0003618791997723747, + "iqr": 0.0001144876005128026, + "q1": 0.00034380409997538666, + "q3": 0.00045829170048818926, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0003189583992934786, + "hd15iqr": 0.000509766599861905, + "ops": 2532.0062040350153, + "total": 0.007898874800594058, + "data": [ + 0.00035667499905684964, + 0.0003298832001746632, + 0.00032590000046184286, + 0.00036708340048789977, + 0.0003510167996864766, + 0.0004808249999769032, + 0.0003432249999605119, + 0.000509766599861905, + 0.0004797000001417473, + 0.00039522500010207294, + 0.0003510582013404928, + 0.00040963339997688306, + 0.00046789180050836875, + 0.0004486916004680097, + 0.00044824999931734053, + 0.00034438319999026136, + 0.00035058339999523015, + 0.0003189583992934786, + 0.00032823319925228133, + 0.0004918916005408391 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[STOCH-libs11]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[STOCH-libs11]", + "params": { + "indicator": "STOCH", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "STOCH-libs11", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002480933199694846, + "max": 0.0027984083993942478, + "mean": 0.0026606753800297155, + "stddev": 8.203168763829213e-05, + "rounds": 20, + "median": 0.0026674458007619247, + "iqr": 0.00010783750039990939, + "q1": 0.002617699899565196, + "q3": 0.0027255373999651054, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.002480933199694846, + "hd15iqr": 0.0027984083993942478, + "ops": 375.84442187337845, + "total": 0.05321350760059431, + "data": [ + 0.0026757416009786537, + 0.0027535250002983956, + 0.002592483400076162, + 0.002480933199694846, + 0.002735758200287819, + 0.0026585334009723736, + 0.0027508582003065384, + 0.002659150000545196, + 0.0026185581999015996, + 0.0027153165996423923, + 0.002742383199802134, + 0.002616841599228792, + 0.0026932084001600742, + 0.0025111750001087785, + 0.0025728831999003885, + 0.0026971418003086, + 0.0026188415999058635, + 0.002690450000227429, + 0.0027984083993942478, + 0.002631316598854028 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_large_dataset[SMA]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[SMA]", + "params": { + "indicator": "SMA" + }, + "param": "SMA", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00022538900035821521, + "max": 0.00037056966781771433, + "mean": 0.0002529292335869589, + "stddev": 4.417250096630181e-05, + "rounds": 10, + "median": 0.00023604150070847635, + "iqr": 1.7777664955550193e-05, + "q1": 0.00023022233411514512, + "q3": 0.0002479999990706953, + "iqr_outliers": 2, + "stddev_outliers": 1, + "outliers": "1;2", + "ld15iqr": 0.00022538900035821521, + "hd15iqr": 0.00027806966681964695, + "ops": 3953.67504901798, + "total": 0.0025292923358695893, + "data": [ + 0.00023976366840846217, + 0.00023022233411514512, + 0.0002273613329937992, + 0.00023043066660951203, + 0.00022538900035821521, + 0.00027806966681964695, + 0.00037056966781771433, + 0.0002479999990706953, + 0.0002323193330084905, + 0.00024716666666790843 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[EMA]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[EMA]", + "params": { + "indicator": "EMA" + }, + "param": "EMA", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00034740299937160063, + "max": 0.0005852359997030968, + "mean": 0.0004130597662879154, + "stddev": 8.382708964817365e-05, + "rounds": 10, + "median": 0.0003792223336252694, + "iqr": 4.875000255803269e-05, + "q1": 0.0003642083320301026, + "q3": 0.0004129583345881353, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.00034740299937160063, + "hd15iqr": 0.0005503473318337152, + "ops": 2420.9571631408157, + "total": 0.004130597662879154, + "data": [ + 0.0003812223343023409, + 0.0005852359997030968, + 0.0004129583345881353, + 0.0003661250011646189, + 0.0003600416651655299, + 0.00038583333177181583, + 0.0003772223329481979, + 0.0005503473318337152, + 0.0003642083320301026, + 0.00034740299937160063 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[RSI]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[RSI]", + "params": { + "indicator": "RSI" + }, + "param": "RSI", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000600069334420065, + "max": 0.0007878193331028646, + "mean": 0.0006891180331876967, + "stddev": 6.773662503176818e-05, + "rounds": 10, + "median": 0.0006752223331811062, + "iqr": 0.00010751399774259574, + "q1": 0.0006462360009512244, + "q3": 0.0007537499986938201, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.000600069334420065, + "hd15iqr": 0.0007878193331028646, + "ops": 1451.1302154933271, + "total": 0.006891180331876967, + "data": [ + 0.0006517359991751922, + 0.0007878193331028646, + 0.0006110139996356642, + 0.000600069334420065, + 0.0006590416645243143, + 0.0007834026667599877, + 0.0007537499986938201, + 0.000691403001837898, + 0.0006462360009512244, + 0.0007067083327759368 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[MACD]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[MACD]", + "params": { + "indicator": "MACD" + }, + "param": "MACD", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005782500011264347, + "max": 0.0007826806662099747, + "mean": 0.0006408722331495179, + "stddev": 6.489620407795032e-05, + "rounds": 10, + "median": 0.0006145068327896297, + "iqr": 5.598633288173005e-05, + "q1": 0.0006021943336236291, + "q3": 0.0006581806665053591, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0005782500011264347, + "hd15iqr": 0.0007826806662099747, + "ops": 1560.3734227734847, + "total": 0.00640872233149518, + "data": [ + 0.0007826806662099747, + 0.0006159583329766368, + 0.0006130553326026226, + 0.0005782916656850526, + 0.0005782500011264347, + 0.000715736333707658, + 0.0006581806665053591, + 0.0006097083338924373, + 0.0006021943336236291, + 0.0006546666651653746 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[ATR]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[ATR]", + "params": { + "indicator": "ATR" + }, + "param": "ATR", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005946386663708836, + "max": 0.0007365973336466899, + "mean": 0.0006466179996399054, + "stddev": 5.110334315120765e-05, + "rounds": 10, + "median": 0.0006296111666112363, + "iqr": 8.97640008285332e-05, + "q1": 0.0006081803318617555, + "q3": 0.0006979443326902887, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0005946386663708836, + "hd15iqr": 0.0007365973336466899, + "ops": 1546.5081401335708, + "total": 0.006466179996399053, + "data": [ + 0.000594930665101856, + 0.0005946386663708836, + 0.000629069332111006, + 0.0006414306650791938, + 0.0007141386668081395, + 0.0006081803318617555, + 0.0006979443326902887, + 0.0006301530011114664, + 0.0006190970016177744, + 0.0007365973336466899 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[BBANDS]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[BBANDS]", + "params": { + "indicator": "BBANDS" + }, + "param": "BBANDS", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003216110004965837, + "max": 0.0005164860000756258, + "mean": 0.0003732999665468621, + "stddev": 6.209120141730232e-05, + "rounds": 10, + "median": 0.00034969450037654803, + "iqr": 7.395866608324769e-05, + "q1": 0.00032977766628998023, + "q3": 0.0004037363323732279, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0003216110004965837, + "hd15iqr": 0.0005164860000756258, + "ops": 2678.8108481506265, + "total": 0.0037329996654686206, + "data": [ + 0.00034565266832942143, + 0.0004037363323732279, + 0.00032977766628998023, + 0.0003216110004965837, + 0.000323763665316316, + 0.00036163899979631725, + 0.0005164860000756258, + 0.0003407776675885543, + 0.0004358193327789195, + 0.0003537363324236746 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[OBV]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[OBV]", + "params": { + "indicator": "OBV" + }, + "param": "OBV", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00044651366624748334, + "max": 0.0006777499996436139, + "mean": 0.0005403874665110683, + "stddev": 8.862267895374138e-05, + "rounds": 10, + "median": 0.0005186596669470115, + "iqr": 0.0001556806649508265, + "q1": 0.0004624583331557612, + "q3": 0.0006181389981065877, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.00044651366624748334, + "hd15iqr": 0.0006777499996436139, + "ops": 1850.5240442683694, + "total": 0.005403874665110682, + "data": [ + 0.00044651366624748334, + 0.0006777499996436139, + 0.0005513889991561882, + 0.0004859303347378348, + 0.00046744433348067105, + 0.0004624583331557612, + 0.0005663886671148551, + 0.0006181389981065877, + 0.0006656529997902302, + 0.000462208333677457 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[CCI]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[CCI]", + "params": { + "indicator": "CCI" + }, + "param": "CCI", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008119026679196395, + "max": 0.0010690416650807795, + "mean": 0.0009010055332813256, + "stddev": 8.526608570891659e-05, + "rounds": 10, + "median": 0.0008731041658999553, + "iqr": 0.0001106110018251153, + "q1": 0.0008304999986042579, + "q3": 0.0009411110004293732, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0008119026679196395, + "hd15iqr": 0.0010690416650807795, + "ops": 1109.8710974150752, + "total": 0.009010055332813257, + "data": [ + 0.0010690416650807795, + 0.0008742083324856745, + 0.0009132220002356917, + 0.0008275556659403568, + 0.0008119026679196395, + 0.0008719999993142361, + 0.0010165833349068028, + 0.0008304999986042579, + 0.0008539306678964446, + 0.0009411110004293732 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[ADX]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[ADX]", + "params": { + "indicator": "ADX" + }, + "param": "ADX", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007418056654084163, + "max": 0.0011887083334537845, + "mean": 0.0008643930666342688, + "stddev": 0.00013523304582108202, + "rounds": 10, + "median": 0.0008226111664650185, + "iqr": 9.762533348596969e-05, + "q1": 0.0007768053328618407, + "q3": 0.0008744306663478104, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0007418056654084163, + "hd15iqr": 0.0011887083334537845, + "ops": 1156.8810979636276, + "total": 0.008643930666342689, + "data": [ + 0.0008744306663478104, + 0.0009943749998152878, + 0.0007768053328618407, + 0.000863347333506681, + 0.0011887083334537845, + 0.000794291668474519, + 0.0007959723322225424, + 0.0008492500007074947, + 0.0007418056654084163, + 0.0007649443335443115 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[MFI]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[MFI]", + "params": { + "indicator": "MFI" + }, + "param": "MFI", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003194029995938763, + "max": 0.0004913333323202096, + "mean": 0.0003576916332046191, + "stddev": 6.038320702172412e-05, + "rounds": 10, + "median": 0.00032843733424670063, + "iqr": 3.7721998523920774e-05, + "q1": 0.00032180566631723195, + "q3": 0.00035952766484115273, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0003194029995938763, + "hd15iqr": 0.000445291666740862, + "ops": 2795.7041964913547, + "total": 0.0035769163320461908, + "data": [ + 0.0004913333323202096, + 0.00032976366734753054, + 0.00032656933278000605, + 0.00032180566631723195, + 0.00035952766484115273, + 0.00033452766607903567, + 0.0003194029995938763, + 0.000445291666740862, + 0.0003271110011458707, + 0.00032158333488041535 + ], + "iterations": 3 + } + } + ], + "datetime": "2026-03-23T17:14:05.427766+00:00", + "version": "5.2.3" +} diff --git a/ferro-ta-main/benchmarks/run_perf_contract.py b/ferro-ta-main/benchmarks/run_perf_contract.py new file mode 100644 index 0000000..2a926fa --- /dev/null +++ b/ferro-ta-main/benchmarks/run_perf_contract.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path +from typing import Any + +import numpy as np + +try: + from benchmarks.bench_batch import run_batch_benchmark + from benchmarks.bench_simd import run_simd_benchmark + from benchmarks.bench_streaming import run_streaming_benchmark + from benchmarks.bench_vs_talib import run_comparison + from benchmarks.metadata import benchmark_metadata, file_info + from benchmarks.profile_runtime_hotspots import build_hotspot_report + from benchmarks.test_benchmark_suite import ( + FIXTURE_PATH, + INDICATOR_SUITE, + _run_indicator, + ) +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from bench_batch import run_batch_benchmark + from bench_simd import run_simd_benchmark + from bench_streaming import run_streaming_benchmark + from bench_vs_talib import run_comparison + from metadata import benchmark_metadata, file_info + from profile_runtime_hotspots import build_hotspot_report + from test_benchmark_suite import FIXTURE_PATH, INDICATOR_SUITE, _run_indicator + + +def _time_min(fn, rounds: int = 5) -> float: + fn() + samples: list[float] = [] + for _ in range(rounds): + t0 = time.perf_counter() + fn() + samples.append(time.perf_counter() - t0) + return min(samples) * 1000.0 + + +def build_indicator_latency_report(*, rounds: int = 5) -> dict[str, Any]: + if not FIXTURE_PATH.exists(): + raise FileNotFoundError( + f"Canonical fixture not found: {FIXTURE_PATH}. " + "Run benchmarks/fixtures/generate_canonical.py first." + ) + + fixture = np.load(FIXTURE_PATH) + ohlcv = {key: fixture[key] for key in fixture.files} + + rows: list[dict[str, Any]] = [] + for entry in INDICATOR_SUITE: + elapsed_ms = _time_min( + lambda entry=entry: _run_indicator(entry, ohlcv), rounds=rounds + ) + rows.append( + { + "name": entry["name"], + "inputs": entry["inputs"], + "kwargs": entry["kwargs"], + "elapsed_ms": round(elapsed_ms, 4), + } + ) + + rows.sort(key=lambda row: float(row["elapsed_ms"]), reverse=True) + return { + "metadata": benchmark_metadata( + "indicator_latency", + fixtures=[FIXTURE_PATH], + extra={ + "dataset": { + "fixture": str(FIXTURE_PATH), + "bars": len(ohlcv["close"]), + "rounds": rounds, + } + }, + ), + "results": rows, + } + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate reproducible performance baseline artifacts." + ) + parser.add_argument( + "--output-dir", + default="benchmarks/artifacts/latest", + help="Directory where benchmark JSON artifacts are written", + ) + parser.add_argument("--indicator-rounds", type=int, default=5) + parser.add_argument("--batch-samples", type=int, default=100_000) + parser.add_argument("--batch-series", type=int, default=100) + parser.add_argument("--batch-seed", type=int, default=42) + parser.add_argument("--streaming-bars", type=int, default=100_000) + parser.add_argument("--streaming-seed", type=int, default=2026) + parser.add_argument("--price-bars", type=int, default=20_000) + parser.add_argument("--iv-bars", type=int, default=50_000) + parser.add_argument("--window", type=int, default=252) + parser.add_argument( + "--skip-simd", + action="store_true", + help="Skip portable-vs-SIMD comparison", + ) + parser.add_argument( + "--talib-sizes", + type=int, + nargs="+", + default=[10_000, 100_000], + help="Bar counts used for the TA-Lib comparison suite", + ) + parser.add_argument( + "--skip-talib", + action="store_true", + help="Skip the TA-Lib comparison artifact", + ) + args = parser.parse_args() + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + artifacts: dict[str, str] = {} + + indicator_path = output_dir / "indicator_latency.json" + _write_json( + indicator_path, + build_indicator_latency_report(rounds=args.indicator_rounds), + ) + artifacts["indicator_latency"] = str(indicator_path) + + batch_path = output_dir / "batch.json" + _write_json( + batch_path, + run_batch_benchmark( + n_samples=args.batch_samples, + n_series=args.batch_series, + seed=args.batch_seed, + ), + ) + artifacts["batch"] = str(batch_path) + + streaming_path = output_dir / "streaming.json" + _write_json( + streaming_path, + run_streaming_benchmark( + n_bars=args.streaming_bars, + seed=args.streaming_seed, + ), + ) + artifacts["streaming"] = str(streaming_path) + + hotspot_path = output_dir / "runtime_hotspots.json" + _write_json( + hotspot_path, + build_hotspot_report( + price_bars=args.price_bars, + iv_bars=args.iv_bars, + window=args.window, + ), + ) + artifacts["runtime_hotspots"] = str(hotspot_path) + + if not args.skip_simd: + simd_path = output_dir / "simd.json" + _write_json( + simd_path, + run_simd_benchmark( + price_bars=args.price_bars, + iv_bars=args.iv_bars, + window=args.window, + ), + ) + artifacts["simd"] = str(simd_path) + + if not args.skip_talib: + talib_path = output_dir / "benchmark_vs_talib.json" + run_comparison(args.talib_sizes, str(talib_path)) + artifacts["benchmark_vs_talib"] = str(talib_path) + + wasm_path = output_dir / "wasm.json" + if wasm_path.exists(): + artifacts["wasm"] = str(wasm_path) + + manifest = { + "metadata": benchmark_metadata( + "perf_contract", + fixtures=[FIXTURE_PATH], + extra={"output_dir": str(output_dir)}, + ), + "artifacts": {name: file_info(path) for name, path in artifacts.items()}, + } + manifest_path = output_dir / "manifest.json" + _write_json(manifest_path, manifest) + + print(f"Generated performance contract artifacts in {output_dir}") + for name, path in artifacts.items(): + print(f" - {name}: {path}") + print(f" - manifest: {manifest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/benchmarks/test_accuracy.py b/ferro-ta-main/benchmarks/test_accuracy.py new file mode 100644 index 0000000..c2d2e73 --- /dev/null +++ b/ferro-ta-main/benchmarks/test_accuracy.py @@ -0,0 +1,200 @@ +""" +Cross-library accuracy tests. + +For each indicator we compare ferro_ta output against every available reference library. +Tolerances are based on known algorithmic differences (e.g. Wilder vs SMA seed). +We only compare the overlapping (valid) suffix of each output array. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from benchmarks.data_generator import MEDIUM +from benchmarks.wrapper_registry import ( + BINARY_INDICATORS, + CUMULATIVE_INDICATORS, + INDICATOR_CATEGORIES, + INDICATOR_NAMES, + available_libraries, + execute_indicator, + is_supported, +) + +# Reference = ferro_ta; compare against each library that has a non-empty result. +REFERENCE_LIB = "ferro_ta" +COMPARISON_LIBS = [ + library for library in available_libraries() if library != REFERENCE_LIB +] + +# Per-indicator tolerances (rtol, atol) +_TOLERANCES: dict[str, tuple[float, float]] = { + "ATR": (1e-3, 0.05), # Wilder's smoothing seed differs + "NATR": (1e-3, 0.10), + "BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1 + "STDDEV": (1e-3, 0.20), + "VAR": (1e-3, 0.50), + "MACD": (1e-3, 1.00), # seed differences across libraries + "KAMA": (1e-3, 1e-3), + "STOCH": (1e-3, 0.10), # smoothing method differences + "SAR": (1e-3, 0.20), + "ADOSC": (1e-3, 0.20), + "ADX": (1e-3, 0.50), # Wilder's ADX + "PLUS_DI": (1e-3, 0.50), + "MINUS_DI": (1e-3, 0.50), + "PPO": (1e-2, 1e-3), + "CMO": (1e-3, 0.10), + "TRIX": (1e-3, 0.05), + "CCI": (1e-3, 0.10), + "SUPERTREND": (1e-2, 0.50), + "KELTNER_CHANNELS": (1e-2, 0.50), + "DONCHIAN": (1e-4, 1e-4), + "HT_DCPERIOD": (1e-2, 2.0), + "VWAP": (1e-3, 0.10), + "AROON": (1e-4, 1e-3), + "LINEARREG": (1e-4, 1e-4), + "LINEARREG_SLOPE": (1e-4, 1e-4), + "CORREL": (1e-4, 1e-3), + "BETA": (1e-3, 1e-3), + "TSF": (1e-4, 1e-4), + "EMA": (1e-3, 0.30), # ta library uses different EMA seed + "DEMA": (1e-3, 0.50), + "TEMA": (1e-3, 0.50), + "T3": (1e-3, 0.50), + "HULL_MA": (1e-3, 0.10), + "WMA": (1e-4, 1e-4), + "TRIMA": (1e-4, 1e-4), +} + +_DEFAULT_TOL = (1e-4, 1e-5) + +# Pairs that use correlation check (>=0.95) due to known algorithmic divergence +# Format: (indicator, library) or just indicator (applies to all libs) +_CORRELATION_PAIRS: set[tuple[str, str]] = { + ("PPO", "talib"), # different PPO formula normalization + ("PPO", "pandas_ta"), + ("PPO", "tulipy"), + ("STOCH", "ta"), + ("SUPERTREND", "pandas_ta"), + ("KELTNER_CHANNELS", "pandas_ta"), + ("KELTNER_CHANNELS", "ta"), + ("EMA", "finta"), # finta EMA uses different initialization + ("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed + ("RSI", "ta"), # ta uses SMA warmup vs Wilder + ("RSI", "finta"), # same +} + +# Pairs that are skipped because they are structurally incompatible +_SKIP_PAIRS: set[tuple[str, str]] = { + ("BBANDS", "finta"), # finta normalizes band differently + ("ATR", "finta"), # finta ATR uses simple TR not Wilder + ("STDDEV", "finta"), # finta uses population std + ("TRIMA", "finta"), # finta TRIMA uses different formula + ("PPO", "finta"), # finta PPO scaling incompatible + ("STOCH", "finta"), # finta STOCH formula differs + ("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start + ("HT_TRENDMODE", "talib"), # binary; Hilbert seed diverges + ("CMO", "talib"), # ferro_ta CMO smoothing variant corr < 0.90 + ("CMO", "pandas_ta"), + ("CMO", "finta"), + ("PLUS_DI", "pandas_ta"), # pandas_ta ADX column naming corr < 0.70 +} + +MIN_OVERLAP = 30 # minimum points to make comparison meaningful + + +def _compare(ref: np.ndarray, cmp: np.ndarray, indicator: str, library: str) -> None: + """Assert that ref and cmp agree on their overlapping suffix.""" + if (indicator, library) in _SKIP_PAIRS: + pytest.skip(f"Known structural incompatibility: {indicator} vs {library}") + if len(ref) < MIN_OVERLAP or len(cmp) < MIN_OVERLAP: + pytest.skip(f"Too few points to compare ({len(ref)} vs {len(cmp)})") + n = min(len(ref), len(cmp)) + r = ref[-n:] + c = cmp[-n:] + if indicator in BINARY_INDICATORS or (indicator, library) in _CORRELATION_PAIRS: + # Use correlation check for structurally different algorithms + corr = np.corrcoef(r, c)[0, 1] if indicator not in BINARY_INDICATORS else None + if indicator in BINARY_INDICATORS: + agree = np.mean(r == c) + assert agree >= 0.80, f"Binary agreement {agree:.1%} < 80%" + else: + assert corr >= 0.90, ( + f"Correlation {corr:.4f} < 0.90 (structural divergence)" + ) + elif indicator in CUMULATIVE_INDICATORS: + dr, dc = np.diff(r), np.diff(c) + if len(dr) < 5 or len(dc) < 5: + return + corr = np.corrcoef(dr, dc)[0, 1] + assert corr >= 0.999, f"Cumulative corr {corr:.6f} < 0.999" + else: + rtol, atol = _TOLERANCES.get(indicator, _DEFAULT_TOL) + assert np.allclose(r, c, rtol=rtol, atol=atol), ( + f"max diff = {np.max(np.abs(r - c)):.6g}, " + f"mean diff = {np.mean(np.abs(r - c)):.6g}" + ) + + +# ── dynamically generate one test per (indicator, library) pair ───────────── + + +def pytest_generate_tests(metafunc): + if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames: + params = [] + avail = available_libraries() + for ind in INDICATOR_NAMES: + for lib in COMPARISON_LIBS: + if lib in avail: + params.append(pytest.param(ind, lib, id=f"{ind}-{lib}")) + metafunc.parametrize("indicator,library", params) + + +class TestAccuracy: + """Compare ferro_ta vs every other library for all indicators.""" + + def test_accuracy(self, indicator, library): + """ferro_ta and {library} should agree on {indicator}.""" + if not is_supported(REFERENCE_LIB, indicator): + pytest.fail(f"{REFERENCE_LIB} does not implement {indicator}") + if not is_supported(library, indicator): + pytest.skip(f"{library} does not implement {indicator}") + + ref = execute_indicator(REFERENCE_LIB, indicator, MEDIUM) + cmp = execute_indicator(library, indicator, MEDIUM) + + if len(cmp) == 0: + pytest.fail( + f"{library} returned empty output for supported indicator {indicator}" + ) + if len(ref) == 0: + pytest.fail(f"{REFERENCE_LIB} returned empty for {indicator}") + + _compare(ref, cmp, indicator, library) + + +# ── quick smoke tests that always run (no skip) ────────────────────────────── + + +class TestSmoke: + """Sanity checks that ferro_ta returns non-empty finite arrays.""" + + @pytest.mark.parametrize("indicator", INDICATOR_NAMES) + def test_ferro_ta_returns_finite(self, indicator): + if not is_supported("ferro_ta", indicator): + pytest.fail(f"ferro_ta does not implement {indicator}") + + arr = execute_indicator("ferro_ta", indicator, MEDIUM) + assert len(arr) > 0, f"ferro_ta {indicator} returned empty array" + assert np.all(np.isfinite(arr)), ( + f"ferro_ta {indicator} has non-finite values: {arr[~np.isfinite(arr)][:5]}" + ) + + @pytest.mark.parametrize("category,indicators", INDICATOR_CATEGORIES.items()) + def test_category_coverage(self, category, indicators): + for ind in indicators: + if not is_supported("ferro_ta", ind): + pytest.fail(f"Category {category}: ferro_ta does not implement {ind}") + arr = execute_indicator("ferro_ta", ind, MEDIUM) + assert len(arr) > 0, f"Category {category}: {ind} returned empty" diff --git a/ferro-ta-main/benchmarks/test_benchmark_suite.py b/ferro-ta-main/benchmarks/test_benchmark_suite.py new file mode 100644 index 0000000..05c669a --- /dev/null +++ b/ferro-ta-main/benchmarks/test_benchmark_suite.py @@ -0,0 +1,370 @@ +""" +Benchmark suite +=========================== + +Numerical-regression and performance benchmarks that run against the canonical +OHLCV fixture in ``benchmarks/fixtures/canonical_ohlcv.npz``. + +Numerical regression checks +---------------------------- +For each (indicator, params) pair in ``INDICATOR_SUITE``, the test: +1. Loads the canonical dataset. +2. Runs the indicator. +3. Compares the last N non-NaN values to stored baselines (or tolerance-based). + +To regenerate baselines after an intentional indicator change:: + + pytest benchmarks/test_benchmark_suite.py --update-baselines + +Performance checks +------------------ +Each indicator is timed over the canonical dataset. If a ``baselines.npz`` +file exists in this directory, the run compares to that; otherwise timing is +reported only. + +Run locally:: + + pytest benchmarks/test_benchmark_suite.py -v + +""" + +from __future__ import annotations + +import pathlib +import time +from collections.abc import Callable +from typing import Any + +import numpy as np +import pytest + +FIXTURE_PATH = pathlib.Path(__file__).parent / "fixtures" / "canonical_ohlcv.npz" +BASELINE_PATH = pathlib.Path(__file__).parent / "baselines.npz" + +# --------------------------------------------------------------------------- +# Load fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def ohlcv() -> dict[str, np.ndarray]: + """Load canonical OHLCV fixture.""" + if not FIXTURE_PATH.exists(): + pytest.skip(f"Canonical fixture not found: {FIXTURE_PATH}") + data = np.load(FIXTURE_PATH) + return {k: data[k] for k in data.files} + + +# --------------------------------------------------------------------------- +# Indicator suite definition +# --------------------------------------------------------------------------- + +# Each entry: (name, callable, kwargs) +# The callable receives (close,) or (high, low, close,) based on 'inputs' key. +INDICATOR_SUITE: list[dict[str, Any]] = [ + { + "name": "SMA_20", + "inputs": "close", + "fn": None, + "fn_name": "SMA", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "EMA_20", + "inputs": "close", + "fn": None, + "fn_name": "EMA", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "RSI_14", + "inputs": "close", + "fn": None, + "fn_name": "RSI", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "ATR_14", + "inputs": "hlc", + "fn": None, + "fn_name": "ATR", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "ADX_14", + "inputs": "hlc", + "fn": None, + "fn_name": "ADX", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "STDDEV_20", + "inputs": "close", + "fn": None, + "fn_name": "STDDEV", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "MACD", + "inputs": "close", + "fn": None, + "fn_name": "MACD", + "kwargs": {}, + }, + { + "name": "BBANDS_20", + "inputs": "close", + "fn": None, + "fn_name": "BBANDS", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "STOCH", + "inputs": "hlc", + "fn": None, + "fn_name": "STOCH", + "kwargs": {}, + }, + { + "name": "LINEARREG_14", + "inputs": "close", + "fn": None, + "fn_name": "LINEARREG", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "LINEARREG_SLOPE_14", + "inputs": "close", + "fn": None, + "fn_name": "LINEARREG_SLOPE", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "TSF_14", + "inputs": "close", + "fn": None, + "fn_name": "TSF", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "VAR_20", + "inputs": "close", + "fn": None, + "fn_name": "VAR", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "CORREL_30", + "inputs": "pair_hl", + "fn": None, + "fn_name": "CORREL", + "kwargs": {"timeperiod": 30}, + }, + { + "name": "BETA_5", + "inputs": "pair_hl", + "fn": None, + "fn_name": "BETA", + "kwargs": {"timeperiod": 5}, + }, + { + "name": "CCI_14", + "inputs": "hlc", + "fn": None, + "fn_name": "CCI", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "WILLR_14", + "inputs": "hlc", + "fn": None, + "fn_name": "WILLR", + "kwargs": {"timeperiod": 14}, + }, +] + + +def _load_fn(fn_name: str) -> Callable[..., Any]: + import ferro_ta as ft + + return getattr(ft, fn_name) + + +def _run_indicator(entry: dict[str, Any], data: dict[str, np.ndarray]) -> np.ndarray: + fn = _load_fn(entry["fn_name"]) + if entry["inputs"] == "close": + result = fn(data["close"], **entry["kwargs"]) + elif entry["inputs"] == "hlc": + result = fn(data["high"], data["low"], data["close"], **entry["kwargs"]) + else: # pair_hl + result = fn(data["high"], data["low"], **entry["kwargs"]) + if isinstance(result, tuple): + result = result[0] + return np.asarray(result, dtype=np.float64) + + +# --------------------------------------------------------------------------- +# Numerical regression tests +# --------------------------------------------------------------------------- + + +class TestNumericalRegression: + """Verify indicator outputs match stored baselines (or tolerance).""" + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_output_shape( + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] + ) -> None: + """Indicator output length must equal input length.""" + out = _run_indicator(entry, ohlcv) + assert len(out) == len(ohlcv["close"]), ( + f"{entry['name']}: expected len {len(ohlcv['close'])}, got {len(out)}" + ) + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_warmup_is_nan( + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] + ) -> None: + """First bar must be NaN (warm-up).""" + out = _run_indicator(entry, ohlcv) + assert np.isnan(out[0]), f"{entry['name']}: expected NaN at bar 0, got {out[0]}" + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_no_inf(self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]) -> None: + """Output must not contain infinities.""" + out = _run_indicator(entry, ohlcv) + assert not np.any(np.isinf(out)), f"{entry['name']}: output contains Inf" + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_last_values_stable( + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] + ) -> None: + """Last 10 non-NaN values must be finite and stable (no sudden jumps).""" + out = _run_indicator(entry, ohlcv) + valid = out[~np.isnan(out)] + assert len(valid) >= 10, f"{entry['name']}: fewer than 10 valid output values" + last10 = valid[-10:] + assert np.all(np.isfinite(last10)), ( + f"{entry['name']}: non-finite in last 10 values" + ) + + @pytest.mark.skipif(not BASELINE_PATH.exists(), reason="No baselines.npz found") + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_regression_vs_baseline( + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] + ) -> None: + """Compare last 10 values to stored baselines.""" + baselines = np.load(BASELINE_PATH) + key = entry["name"] + if key not in baselines: + pytest.skip(f"No baseline stored for {key}") + out = _run_indicator(entry, ohlcv) + valid = out[~np.isnan(out)] + last10 = valid[-10:] + stored = baselines[key] + np.testing.assert_allclose( + last10, + stored, + rtol=1e-5, + atol=1e-8, + err_msg=f"Numerical regression for {key}", + ) + + +# --------------------------------------------------------------------------- +# Performance benchmarks +# --------------------------------------------------------------------------- + + +class TestPerformance: + """Timing benchmarks — record wall time and compare to baselines if present.""" + + PERF_THRESHOLD_FACTOR = 2.0 # fail if run is > 2× slower than baseline + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_timing( + self, + entry: dict[str, Any], + ohlcv: dict[str, np.ndarray], + request: pytest.FixtureRequest, + ) -> None: + """Time the indicator on the canonical dataset.""" + # Warm-up run + _run_indicator(entry, ohlcv) + + # Timed run + t0 = time.perf_counter() + for _ in range(5): + _run_indicator(entry, ohlcv) + elapsed = (time.perf_counter() - t0) / 5.0 # average over 5 runs + + # Store timing in request node for reporting + request.node._ferro_ta_timing = elapsed # type: ignore[attr-defined] + + # Compare to baseline if available + if BASELINE_PATH.exists(): + baselines = np.load(BASELINE_PATH, allow_pickle=True) + key = f"timing_{entry['name']}" + if key in baselines: + baseline_time = float(baselines[key]) + if elapsed > baseline_time * self.PERF_THRESHOLD_FACTOR: + pytest.fail( + f"{entry['name']}: timing regression — " + f"current {elapsed * 1000:.2f}ms vs " + f"baseline {baseline_time * 1000:.2f}ms " + f"(>{self.PERF_THRESHOLD_FACTOR}×)" + ) + + +# --------------------------------------------------------------------------- +# Baseline update helper +# --------------------------------------------------------------------------- + + +def update_baselines(ohlcv_data: dict[str, np.ndarray]) -> None: + """Write current indicator outputs and timings to baselines.npz. + + Call this after intentional changes to update the stored baselines:: + + python -c " + import numpy as np + from benchmarks.test_benchmark_suite import update_baselines, FIXTURE_PATH + data = {k: v for k, v in np.load(FIXTURE_PATH).items()} + update_baselines(data) + " + """ + store: dict[str, np.ndarray] = {} + for entry in INDICATOR_SUITE: + out = _run_indicator(entry, ohlcv_data) + valid = out[~np.isnan(out)] + store[entry["name"]] = valid[-10:] + + # Timing + t0 = time.perf_counter() + for _ in range(5): + _run_indicator(entry, ohlcv_data) + store[f"timing_{entry['name']}"] = np.array([(time.perf_counter() - t0) / 5.0]) + + np.savez_compressed(BASELINE_PATH, **store) + print(f"Baselines written to {BASELINE_PATH}") + + +if __name__ == "__main__": + if not FIXTURE_PATH.exists(): + print(f"Fixture not found: {FIXTURE_PATH}") + print("Run: python benchmarks/fixtures/generate_canonical.py") + else: + data = {k: v for k, v in np.load(FIXTURE_PATH).items()} + update_baselines(data) diff --git a/ferro-ta-main/benchmarks/test_derivatives_speed.py b/ferro-ta-main/benchmarks/test_derivatives_speed.py new file mode 100644 index 0000000..a64ba0e --- /dev/null +++ b/ferro-ta-main/benchmarks/test_derivatives_speed.py @@ -0,0 +1,124 @@ +""" +Derivatives benchmark hooks. + +These are intentionally optional and skip when `py_vollib` is unavailable. +Run with: + + uv run pytest benchmarks/test_derivatives_speed.py --benchmark-only -v +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pytest + +# Ensure direct benchmark test runs can import local package from `python/`. +ROOT = Path(__file__).resolve().parents[1] +PYTHON_SRC = ROOT / "python" +if str(PYTHON_SRC) not in sys.path: + sys.path.insert(0, str(PYTHON_SRC)) + +HAS_FERRO_EXTENSION = True +try: + from ferro_ta.analysis.options import implied_volatility, option_price +except ModuleNotFoundError: + HAS_FERRO_EXTENSION = False + +pytestmark = pytest.mark.skipif( + not HAS_FERRO_EXTENSION, reason="ferro_ta extension is not built" +) + + +def _sample_chain(n: int = 1000) -> tuple[np.ndarray, ...]: + spot = np.linspace(90.0, 110.0, n) + strike = np.full(n, 100.0) + rate = np.full(n, 0.02) + time_to_expiry = np.full(n, 0.5) + volatility = np.full(n, 0.2) + return spot, strike, rate, time_to_expiry, volatility + + +def test_ferro_ta_option_price_speed(benchmark): + spot, strike, rate, time_to_expiry, volatility = _sample_chain() + + benchmark.pedantic( + lambda: option_price( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + model="bsm", + ), + iterations=5, + rounds=20, + warmup_rounds=2, + ) + + +def test_ferro_ta_implied_vol_speed(benchmark): + spot, strike, rate, time_to_expiry, volatility = _sample_chain() + prices = option_price( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + model="bsm", + ) + + benchmark.pedantic( + lambda: implied_volatility( + prices, + spot, + strike, + rate, + time_to_expiry, + option_type="call", + model="bsm", + ), + iterations=5, + rounds=20, + warmup_rounds=2, + ) + + +@pytest.mark.skipif( + importlib.util.find_spec("py_vollib") is None, + reason="py_vollib is optional", +) +def test_py_vollib_scalar_loop_baseline(benchmark): + from py_vollib.black_scholes_merton import black_scholes_merton as py_vollib_bsm + from py_vollib.black_scholes_merton.implied_volatility import ( + implied_volatility as py_vollib_iv, + ) + + spot, strike, rate, time_to_expiry, volatility = _sample_chain(250) + prices = [ + py_vollib_bsm("c", float(s), float(k), float(t), float(r), float(vol), 0.0) + for s, k, r, t, vol in zip(spot, strike, rate, time_to_expiry, volatility) + ] + + benchmark.pedantic( + lambda: [ + py_vollib_iv( + float(price), + "c", + float(s), + float(k), + float(t), + float(r), + 0.0, + ) + for price, s, k, r, t in zip(prices, spot, strike, rate, time_to_expiry) + ], + iterations=3, + rounds=10, + warmup_rounds=1, + ) diff --git a/ferro-ta-main/benchmarks/test_speed.py b/ferro-ta-main/benchmarks/test_speed.py new file mode 100644 index 0000000..f6fbb8d --- /dev/null +++ b/ferro-ta-main/benchmarks/test_speed.py @@ -0,0 +1,102 @@ +""" +Cross-library speed benchmarks using pytest-benchmark. + +Run: pytest benchmarks/test_speed.py --benchmark-only -v + pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json + +Streaming benchmarks are in test_streaming_speed.py +""" + +from __future__ import annotations + +import pytest + +from benchmarks.data_generator import LARGE +from benchmarks.wrapper_registry import ( + INDICATOR_CATEGORIES, + available_libraries, + execute_indicator, + is_supported, +) + +BENCH_DATA = LARGE # 100k bars for main benchmarks +BENCH_LIBS = available_libraries() + + +def _make_bench(indicator: str, library: str): + """Return a benchmark function that runs indicator on library (uses BENCH_DATA).""" + + def _fn(): + execute_indicator(library, indicator, BENCH_DATA) + + _fn.__name__ = f"{library}_{indicator}" + return _fn + + +# ── Parametrize over all (indicator, library) combinations ─────────────────── + + +def pytest_generate_tests(metafunc): + if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames: + params = [] + for cat, inds in INDICATOR_CATEGORIES.items(): + for ind in inds: + for lib in BENCH_LIBS: + params.append(pytest.param(ind, lib, id=f"{cat}/{ind}/{lib}")) + metafunc.parametrize("indicator,library", params) + + +class TestSpeed: + """One benchmark per (indicator, library) pair — all at 100k bars (LARGE dataset).""" + + def test_speed(self, benchmark, indicator, library): + if not is_supported(library, indicator): + pytest.skip(f"{library} does not implement {indicator}") + fn = _make_bench(indicator, library) + benchmark.pedantic(fn, iterations=5, rounds=20, warmup_rounds=2) + + +# ── Standalone head-to-head for the most important indicators ───────────────── + + +@pytest.mark.parametrize( + "indicator,libs", + [ + ("SMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("EMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("RSI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("MACD", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("BBANDS", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("ATR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("CCI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("WILLR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("OBV", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("ADX", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("MFI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("STOCH", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ], +) +def test_head_to_head(benchmark, indicator, libs): + """Benchmark ferro_ta vs all peers — for README table generation.""" + if not is_supported("ferro_ta", indicator): + pytest.skip(f"ferro_ta does not implement {indicator}") + fn = _make_bench(indicator, "ferro_ta") + benchmark.pedantic(fn, iterations=5, rounds=20, warmup_rounds=2) + + +# ── Large dataset benchmarks (100k bars) ───────────────────────────────────── + + +@pytest.mark.parametrize( + "indicator", + ["SMA", "EMA", "RSI", "MACD", "ATR", "BBANDS", "OBV", "CCI", "ADX", "MFI"], +) +def test_large_dataset(benchmark, indicator): + """Scaling benchmark at 100k bars for ferro_ta.""" + if not is_supported("ferro_ta", indicator): + pytest.skip(f"ferro_ta does not implement {indicator}") + + def _fn(): + execute_indicator("ferro_ta", indicator, LARGE) + + benchmark.pedantic(_fn, iterations=3, rounds=10, warmup_rounds=1) diff --git a/ferro-ta-main/benchmarks/wrapper_registry.py b/ferro-ta-main/benchmarks/wrapper_registry.py new file mode 100644 index 0000000..77ecde9 --- /dev/null +++ b/ferro-ta-main/benchmarks/wrapper_registry.py @@ -0,0 +1,2822 @@ +""" +Cross-library wrapper registry — comprehensive indicator coverage. + +Unified interface: execute_indicator(library, indicator, data, df=None, **kwargs) +Supported libraries: ferro_ta, talib, pandas_ta, ta, tulipy, finta +50+ indicators across all categories. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def _try_import(name): + try: + import importlib + + return importlib.import_module(name) + except ImportError: + return None + + +_talib = _try_import("talib") +_pta = _try_import("pandas_ta") +_ta = _try_import("ta") +_tl = _try_import("tulipy") +_fi_m = _try_import("finta") +_fi = getattr(_fi_m, "TA", None) if _fi_m else None + + +def available_libraries(): + libs = ["ferro_ta"] + if _talib: + libs.append("talib") + if _pta: + libs.append("pandas_ta") + if _ta: + libs.append("ta") + if _tl: + libs.append("tulipy") + if _fi: + libs.append("finta") + return libs + + +def is_supported(library: str, indicator: str) -> bool: + """Return True if a wrapper exists for the given (library, indicator) pair.""" + if library not in available_libraries(): + return False + return (library, indicator) in REGISTRY + + +def _strip_nan(arr): + a = np.asarray(arr, dtype=np.float64).ravel() + return a[np.isfinite(a)] + + +def _c64(a): + return np.ascontiguousarray(a, dtype=np.float64) + + +def _empty(): + return np.array([], dtype=np.float64) + + +def _first_col(df, prefix): + col = next((c for c in df.columns if c.startswith(prefix)), None) + return _strip_nan(df[col].values) if col is not None else _empty() + + +# ============================================================ +# OVERLAP +# ============================================================ +def _sma_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.SMA(d["close"], timeperiod=timeperiod)) + + +def _sma_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.SMA(d["close"], timeperiod=timeperiod)) + + +def _sma_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.sma(df["close"], length=timeperiod).values) + + +def _sma_ta(d, df, timeperiod=20, **_): + from ta.trend import SMAIndicator + + return _strip_nan( + SMAIndicator(df["close"], window=timeperiod).sma_indicator().values + ) + + +def _sma_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.sma(_c64(d["close"]), period=timeperiod)) + + +def _sma_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.SMA(df, timeperiod).values) + + +def _ema_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.EMA(d["close"], timeperiod=timeperiod)) + + +def _ema_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.EMA(d["close"], timeperiod=timeperiod)) + + +def _ema_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.ema(df["close"], length=timeperiod).values) + + +def _ema_ta(d, df, timeperiod=20, **_): + from ta.trend import EMAIndicator + + return _strip_nan( + EMAIndicator(df["close"], window=timeperiod).ema_indicator().values + ) + + +def _ema_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.ema(_c64(d["close"]), period=timeperiod)) + + +def _ema_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.EMA(df, timeperiod).values) + + +def _wma_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.WMA(d["close"], timeperiod=timeperiod)) + + +def _wma_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.WMA(d["close"], timeperiod=timeperiod)) + + +def _wma_pt(d, df, timeperiod=14, **_): + return _strip_nan(_pta.wma(df["close"], length=timeperiod).values) + + +def _wma_ta(d, df, **_): + return _empty() + + +_wma_ta._stub = True + + +def _wma_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.wma(_c64(d["close"]), period=timeperiod)) + + +def _wma_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.WMA(df, timeperiod).values) + + +def _dema_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.DEMA(d["close"], timeperiod=timeperiod)) + + +def _dema_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.DEMA(d["close"], timeperiod=timeperiod)) + + +def _dema_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.dema(df["close"], length=timeperiod).values) + + +def _dema_ta(d, df, **_): + return _empty() + + +_dema_ta._stub = True + + +def _dema_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.dema(_c64(d["close"]), period=timeperiod)) + + +def _dema_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.DEMA(df, timeperiod).values) + + +def _tema_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TEMA(d["close"], timeperiod=timeperiod)) + + +def _tema_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.TEMA(d["close"], timeperiod=timeperiod)) + + +def _tema_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.tema(df["close"], length=timeperiod).values) + + +def _tema_ta(d, df, **_): + return _empty() + + +_tema_ta._stub = True + + +def _tema_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.tema(_c64(d["close"]), period=timeperiod)) + + +def _tema_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.TEMA(df, timeperiod).values) + + +def _t3_ft(d, df, timeperiod=5, **_): + import ferro_ta + + return _strip_nan(ferro_ta.T3(d["close"], timeperiod=timeperiod)) + + +def _t3_tl(d, df, timeperiod=5, **_): + return _strip_nan(_talib.T3(d["close"], timeperiod=timeperiod)) + + +def _t3_pt(d, df, timeperiod=5, **_): + return _strip_nan(_pta.t3(df["close"], length=timeperiod).values) + + +def _t3_ta(d, df, **_): + return _empty() + + +_t3_ta._stub = True + + +def _t3_tu(d, df, **_): + return _empty() + + +_t3_tu._stub = True + + +def _t3_fi(d, df, **_): + return _empty() + + +_t3_fi._stub = True + + +def _trima_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TRIMA(d["close"], timeperiod=timeperiod)) + + +def _trima_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.TRIMA(d["close"], timeperiod=timeperiod)) + + +def _trima_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.trima(df["close"], length=timeperiod).values) + + +def _trima_ta(d, df, **_): + return _empty() + + +_trima_ta._stub = True + + +def _trima_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.trima(_c64(d["close"]), period=timeperiod)) + + +def _trima_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.TRIMA(df, timeperiod).values) + + +def _kama_ft(d, df, timeperiod=10, **_): + import ferro_ta + + return _strip_nan(ferro_ta.KAMA(d["close"], timeperiod=timeperiod)) + + +def _kama_tl(d, df, timeperiod=10, **_): + return _strip_nan(_talib.KAMA(d["close"], timeperiod=timeperiod)) + + +def _kama_pt(d, df, timeperiod=10, **_): + return _strip_nan(_pta.kama(df["close"], length=timeperiod).values) + + +def _kama_ta(d, df, **_): + return _empty() + + +_kama_ta._stub = True + + +def _kama_tu(d, df, timeperiod=10, **_): + return _strip_nan(_tl.kama(_c64(d["close"]), period=timeperiod)) + + +def _kama_fi(d, df, **_): + return _empty() + + +_kama_fi._stub = True + + +def _hma_ft(d, df, timeperiod=16, **_): + import ferro_ta + + return _strip_nan(ferro_ta.HULL_MA(d["close"], timeperiod=timeperiod)) + + +def _hma_tl(d, df, **_): + return _empty() + + +_hma_tl._stub = True + + +def _hma_pt(d, df, timeperiod=16, **_): + return _strip_nan(_pta.hma(df["close"], length=timeperiod).values) + + +def _hma_ta(d, df, **_): + return _empty() + + +_hma_ta._stub = True + + +def _hma_tu(d, df, timeperiod=16, **_): + return _strip_nan(_tl.hma(_c64(d["close"]), period=timeperiod)) + + +def _hma_fi(d, df, timeperiod=16, **_): + return _strip_nan(_fi.HMA(df, timeperiod).values) + + +def _vwma_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.VWMA(d["close"], d["volume"], timeperiod=timeperiod)) + + +def _vwma_tl(d, df, **_): + return _empty() + + +_vwma_tl._stub = True + + +def _vwma_pt(d, df, timeperiod=20, **_): + r = _pta.vwma(df["close"], df["volume"], length=timeperiod) + return _strip_nan(r.values) if r is not None else _empty() + + +def _vwma_ta(d, df, **_): + return _empty() + + +_vwma_ta._stub = True + + +def _vwma_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.vwma(_c64(d["close"]), _c64(d["volume"]), period=timeperiod)) + + +def _vwma_fi(d, df, **_): + return _empty() + + +_vwma_fi._stub = True + + +def _midpoint_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.MIDPOINT(d["close"], timeperiod=timeperiod)) + + +def _midpoint_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.MIDPOINT(d["close"], timeperiod=timeperiod)) + + +def _midpoint_pt(d, df, **_): + return _empty() + + +_midpoint_pt._stub = True + + +def _midpoint_ta(d, df, **_): + return _empty() + + +_midpoint_ta._stub = True + + +def _midpoint_tu(d, df, **_): + return _empty() + + +_midpoint_tu._stub = True + + +def _midpoint_fi(d, df, **_): + return _empty() + + +_midpoint_fi._stub = True + + +def _midprice_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.MIDPRICE(d["high"], d["low"], timeperiod=timeperiod)) + + +def _midprice_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.MIDPRICE(d["high"], d["low"], timeperiod=timeperiod)) + + +def _midprice_pt(d, df, **_): + return _empty() + + +_midprice_pt._stub = True + + +def _midprice_ta(d, df, **_): + return _empty() + + +_midprice_ta._stub = True + + +def _midprice_tu(d, df, **_): + return _empty() + + +_midprice_tu._stub = True + + +def _midprice_fi(d, df, **_): + return _empty() + + +_midprice_fi._stub = True + + +# ============================================================ +# MOMENTUM +# ============================================================ +def _rsi_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.RSI(d["close"], timeperiod=timeperiod)) + + +def _rsi_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.RSI(d["close"], timeperiod=timeperiod)) + + +def _rsi_pt(d, df, timeperiod=14, **_): + return _strip_nan(_pta.rsi(df["close"], length=timeperiod).values) + + +def _rsi_ta(d, df, timeperiod=14, **_): + from ta.momentum import RSIIndicator + + return _strip_nan(RSIIndicator(df["close"], window=timeperiod).rsi().values) + + +def _rsi_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.rsi(_c64(d["close"]), period=timeperiod)) + + +def _rsi_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.RSI(df, timeperiod).values) + + +def _macd_ft(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + import ferro_ta + + m, s, h = ferro_ta.MACD( + d["close"], + fastperiod=fastperiod, + slowperiod=slowperiod, + signalperiod=signalperiod, + ) + return _strip_nan(m) + + +def _macd_tl(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + m, s, h = _talib.MACD( + d["close"], + fastperiod=fastperiod, + slowperiod=slowperiod, + signalperiod=signalperiod, + ) + return _strip_nan(m) + + +def _macd_pt(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + r = _pta.macd(df["close"], fast=fastperiod, slow=slowperiod, signal=signalperiod) + return _first_col(r, "MACD_") + + +def _macd_ta(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + from ta.trend import MACD + + return _strip_nan( + MACD( + df["close"], + window_fast=fastperiod, + window_slow=slowperiod, + window_sign=signalperiod, + ) + .macd() + .values + ) + + +def _macd_tu(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + m, s, h = _tl.macd( + _c64(d["close"]), + short_period=fastperiod, + long_period=slowperiod, + signal_period=signalperiod, + ) + return _strip_nan(m) + + +def _macd_fi(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + return _strip_nan(_fi.MACD(df, fastperiod, slowperiod, signalperiod)["MACD"].values) + + +def _stoch_ft(d, df, fastk_period=14, slowk_period=3, slowd_period=3, **_): + import ferro_ta + + k, dd = ferro_ta.STOCH( + d["high"], + d["low"], + d["close"], + fastk_period=fastk_period, + slowk_period=slowk_period, + slowd_period=slowd_period, + ) + return _strip_nan(k) + + +def _stoch_tl(d, df, fastk_period=14, slowk_period=3, slowd_period=3, **_): + k, dd = _talib.STOCH( + d["high"], + d["low"], + d["close"], + fastk_period=fastk_period, + slowk_period=slowk_period, + slowd_period=slowd_period, + ) + return _strip_nan(k) + + +def _stoch_pt(d, df, fastk_period=14, slowk_period=3, slowd_period=3, **_): + r = _pta.stoch(df["high"], df["low"], df["close"], k=fastk_period, d=slowd_period) + return _first_col(r, "STOCHk_") if r is not None else _empty() + + +def _stoch_ta(d, df, fastk_period=14, **_): + from ta.momentum import StochasticOscillator + + return _strip_nan( + StochasticOscillator(df["high"], df["low"], df["close"], window=fastk_period) + .stoch() + .values + ) + + +def _stoch_tu(d, df, fastk_period=14, slowk_period=3, slowd_period=3, **_): + k, dd = _tl.stoch( + _c64(d["high"]), + _c64(d["low"]), + _c64(d["close"]), + pct_k_period=fastk_period, + pct_k_slowing_period=slowk_period, + pct_d_period=slowd_period, + ) + return _strip_nan(k) + + +def _stoch_fi(d, df, fastk_period=14, **_): + return _strip_nan(_fi.STOCH(df, fastk_period).values) + + +def _cci_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CCI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _cci_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.CCI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _cci_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.cci(df["high"], df["low"], df["close"], length=timeperiod).values + ) + + +def _cci_ta(d, df, timeperiod=14, **_): + from ta.trend import CCIIndicator + + return _strip_nan( + CCIIndicator(df["high"], df["low"], df["close"], window=timeperiod).cci().values + ) + + +def _cci_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.cci(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _cci_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.CCI(df, timeperiod).values) + + +def _willr_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.WILLR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _willr_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.WILLR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _willr_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.willr(df["high"], df["low"], df["close"], length=timeperiod).values + ) + + +def _willr_ta(d, df, timeperiod=14, **_): + from ta.momentum import WilliamsRIndicator + + return _strip_nan( + WilliamsRIndicator(df["high"], df["low"], df["close"], lbp=timeperiod) + .williams_r() + .values + ) + + +def _willr_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.willr(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _willr_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.WILLIAMS(df, timeperiod).values) + + +def _aroon_ft(d, df, timeperiod=14, **_): + import ferro_ta + + dn, up = ferro_ta.AROON(d["high"], d["low"], timeperiod=timeperiod) + return _strip_nan(up) + + +def _aroon_tl(d, df, timeperiod=14, **_): + dn, up = _talib.AROON(d["high"], d["low"], timeperiod=timeperiod) + return _strip_nan(up) + + +def _aroon_pt(d, df, timeperiod=14, **_): + r = _pta.aroon(df["high"], df["low"], length=timeperiod) + return _first_col(r, "AROONU_") if r is not None else _empty() + + +def _aroon_ta(d, df, timeperiod=14, **_): + from ta.trend import AroonIndicator + + return _strip_nan( + AroonIndicator(df["high"], df["low"], window=timeperiod).aroon_up().values + ) + + +def _aroon_tu(d, df, timeperiod=14, **_): + dn, up = _tl.aroon(_c64(d["high"]), _c64(d["low"]), period=timeperiod) + return _strip_nan(up) + + +def _aroon_fi(d, df, **_): + return _empty() + + +_aroon_fi._stub = True + + +def _aroonosc_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.AROONOSC(d["high"], d["low"], timeperiod=timeperiod)) + + +def _aroonosc_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.AROONOSC(d["high"], d["low"], timeperiod=timeperiod)) + + +def _aroonosc_pt(d, df, **_): + return _empty() + + +_aroonosc_pt._stub = True + + +def _aroonosc_ta(d, df, **_): + return _empty() + + +_aroonosc_ta._stub = True + + +def _aroonosc_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.aroonosc(_c64(d["high"]), _c64(d["low"]), period=timeperiod)) + + +def _aroonosc_fi(d, df, **_): + return _empty() + + +_aroonosc_fi._stub = True + + +def _adx_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.ADX(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _adx_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.ADX(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _adx_pt(d, df, timeperiod=14, **_): + r = _pta.adx(df["high"], df["low"], df["close"], length=timeperiod) + return _first_col(r, "ADX_") + + +def _adx_ta(d, df, timeperiod=14, **_): + from ta.trend import ADXIndicator + + return _strip_nan( + ADXIndicator(df["high"], df["low"], df["close"], window=timeperiod).adx().values + ) + + +def _adx_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.adx(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _adx_fi(d, df, **_): + return _empty() + + +_adx_fi._stub = True + + +def _mom_ft(d, df, timeperiod=10, **_): + import ferro_ta + + return _strip_nan(ferro_ta.MOM(d["close"], timeperiod=timeperiod)) + + +def _mom_tl(d, df, timeperiod=10, **_): + return _strip_nan(_talib.MOM(d["close"], timeperiod=timeperiod)) + + +def _mom_pt(d, df, timeperiod=10, **_): + return _strip_nan(_pta.mom(df["close"], length=timeperiod).values) + + +def _mom_ta(d, df, **_): + return _empty() + + +_mom_ta._stub = True + + +def _mom_tu(d, df, timeperiod=10, **_): + return _strip_nan(_tl.mom(_c64(d["close"]), period=timeperiod)) + + +def _mom_fi(d, df, timeperiod=10, **_): + return _strip_nan(_fi.MOM(df, timeperiod).values) + + +def _roc_ft(d, df, timeperiod=10, **_): + import ferro_ta + + return _strip_nan(ferro_ta.ROC(d["close"], timeperiod=timeperiod)) + + +def _roc_tl(d, df, timeperiod=10, **_): + return _strip_nan(_talib.ROC(d["close"], timeperiod=timeperiod)) + + +def _roc_pt(d, df, timeperiod=10, **_): + return _strip_nan(_pta.roc(df["close"], length=timeperiod).values) + + +def _roc_ta(d, df, timeperiod=10, **_): + from ta.momentum import ROCIndicator + + return _strip_nan(ROCIndicator(df["close"], window=timeperiod).roc().values) + + +def _roc_tu(d, df, timeperiod=10, **_): + return _strip_nan(_tl.roc(_c64(d["close"]), period=timeperiod) * 100.0) + + +def _roc_fi(d, df, timeperiod=10, **_): + return _strip_nan(_fi.ROC(df, timeperiod).values) + + +def _cmo_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.CMO(d["close"], timeperiod=timeperiod)) + + +def _cmo_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.CMO(d["close"], timeperiod=timeperiod)) + + +def _cmo_pt(d, df, timeperiod=14, **_): + return _strip_nan(_pta.cmo(df["close"], length=timeperiod).values) + + +def _cmo_ta(d, df, **_): + return _empty() + + +_cmo_ta._stub = True + + +def _cmo_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.cmo(_c64(d["close"]), period=timeperiod)) + + +def _cmo_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.CMO(df, timeperiod).values) + + +def _ppo_ft(d, df, fastperiod=12, slowperiod=26, **_): + import ferro_ta + + ppo, sig, hist = ferro_ta.PPO( + d["close"], fastperiod=fastperiod, slowperiod=slowperiod + ) + return _strip_nan(ppo) + + +def _ppo_tl(d, df, fastperiod=12, slowperiod=26, **_): + return _strip_nan( + _talib.PPO(d["close"], fastperiod=fastperiod, slowperiod=slowperiod) + ) + + +def _ppo_pt(d, df, fastperiod=12, slowperiod=26, **_): + r = _pta.ppo(df["close"], fast=fastperiod, slow=slowperiod) + return _strip_nan(r.iloc[:, 0].values) if r is not None else _empty() + + +def _ppo_ta(d, df, **_): + return _empty() + + +_ppo_ta._stub = True + + +def _ppo_tu(d, df, fastperiod=12, slowperiod=26, **_): + return _strip_nan( + _tl.ppo(_c64(d["close"]), short_period=fastperiod, long_period=slowperiod) + ) + + +def _ppo_fi(d, df, fastperiod=12, slowperiod=26, **_): + return _strip_nan(_fi.PPO(df, fastperiod, slowperiod).values) + + +def _trix_ft(d, df, timeperiod=18, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TRIX(d["close"], timeperiod=timeperiod)) + + +def _trix_tl(d, df, timeperiod=18, **_): + return _strip_nan(_talib.TRIX(d["close"], timeperiod=timeperiod)) + + +def _trix_pt(d, df, timeperiod=18, **_): + r = _pta.trix(df["close"], length=timeperiod) + return _strip_nan(r.iloc[:, 0].values) if r is not None else _empty() + + +def _trix_ta(d, df, timeperiod=18, **_): + from ta.trend import TRIXIndicator + + return _strip_nan(TRIXIndicator(df["close"], window=timeperiod).trix().values) + + +def _trix_tu(d, df, timeperiod=18, **_): + return _strip_nan(_tl.trix(_c64(d["close"]), period=timeperiod)) + + +def _trix_fi(d, df, timeperiod=18, **_): + return _strip_nan(_fi.TRIX(df, timeperiod).values) + + +def _tsf_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TSF(d["close"], timeperiod=timeperiod)) + + +def _tsf_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.TSF(d["close"], timeperiod=timeperiod)) + + +def _tsf_pt(d, df, **_): + return _empty() + + +_tsf_pt._stub = True + + +def _tsf_ta(d, df, **_): + return _empty() + + +_tsf_ta._stub = True + + +def _tsf_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.tsf(_c64(d["close"]), period=timeperiod)) + + +def _tsf_fi(d, df, **_): + return _empty() + + +_tsf_fi._stub = True + + +def _ultosc_ft(d, df, timeperiod1=7, timeperiod2=14, timeperiod3=28, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.ULTOSC( + d["high"], + d["low"], + d["close"], + timeperiod1=timeperiod1, + timeperiod2=timeperiod2, + timeperiod3=timeperiod3, + ) + ) + + +def _ultosc_tl(d, df, timeperiod1=7, timeperiod2=14, timeperiod3=28, **_): + return _strip_nan( + _talib.ULTOSC( + d["high"], + d["low"], + d["close"], + timeperiod1=timeperiod1, + timeperiod2=timeperiod2, + timeperiod3=timeperiod3, + ) + ) + + +def _ultosc_pt(d, df, **_): + return _empty() + + +_ultosc_pt._stub = True + + +def _ultosc_ta(d, df, timeperiod1=7, timeperiod2=14, timeperiod3=28, **_): + from ta.momentum import UltimateOscillator + + return _strip_nan( + UltimateOscillator( + df["high"], + df["low"], + df["close"], + window1=timeperiod1, + window2=timeperiod2, + window3=timeperiod3, + ) + .ultimate_oscillator() + .values + ) + + +def _ultosc_tu(d, df, timeperiod1=7, timeperiod2=14, timeperiod3=28, **_): + return _strip_nan( + _tl.ultosc( + _c64(d["high"]), + _c64(d["low"]), + _c64(d["close"]), + short_period=timeperiod1, + medium_period=timeperiod2, + long_period=timeperiod3, + ) + ) + + +def _ultosc_fi(d, df, **_): + return _empty() + + +_ultosc_fi._stub = True + + +def _bop_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.BOP(d["open"], d["high"], d["low"], d["close"])) + + +def _bop_tl(d, df, **_): + return _strip_nan(_talib.BOP(d["open"], d["high"], d["low"], d["close"])) + + +def _bop_pt(d, df, **_): + r = _pta.bop(df["open"], df["high"], df["low"], df["close"]) + return _strip_nan(r.values) if r is not None else _empty() + + +def _bop_ta(d, df, **_): + return _empty() + + +_bop_ta._stub = True + + +def _bop_tu(d, df, **_): + return _strip_nan( + _tl.bop(_c64(d["open"]), _c64(d["high"]), _c64(d["low"]), _c64(d["close"])) + ) + + +def _bop_fi(d, df, **_): + return _empty() + + +_bop_fi._stub = True + + +def _plusdi_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.PLUS_DI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _plusdi_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.PLUS_DI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _plusdi_pt(d, df, timeperiod=14, **_): + r = _pta.adx(df["high"], df["low"], df["close"], length=timeperiod) + return _first_col(r, "DMP_") if r is not None else _empty() + + +def _plusdi_ta(d, df, **_): + return _empty() + + +_plusdi_ta._stub = True + + +def _plusdi_tu(d, df, timeperiod=14, **_): + pdi, mdi = _tl.di( + _c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod + ) + return _strip_nan(pdi) + + +def _plusdi_fi(d, df, **_): + return _empty() + + +_plusdi_fi._stub = True + + +def _minusdi_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.MINUS_DI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _minusdi_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.MINUS_DI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _minusdi_pt(d, df, **_): + return _empty() + + +_minusdi_pt._stub = True + + +def _minusdi_ta(d, df, **_): + return _empty() + + +_minusdi_ta._stub = True + + +def _minusdi_tu(d, df, timeperiod=14, **_): + pdi, mdi = _tl.di( + _c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod + ) + return _strip_nan(mdi) + + +def _minusdi_fi(d, df, **_): + return _empty() + + +_minusdi_fi._stub = True + + +# ============================================================ +# VOLATILITY +# ============================================================ +def _bb_ft(d, df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, **_): + import ferro_ta + + u, m, l = ferro_ta.BBANDS( + d["close"], timeperiod=timeperiod, nbdevup=nbdevup, nbdevdn=nbdevdn + ) + return _strip_nan(u) + + +def _bb_tl(d, df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, **_): + u, m, l = _talib.BBANDS( + d["close"], timeperiod=timeperiod, nbdevup=nbdevup, nbdevdn=nbdevdn + ) + return _strip_nan(u) + + +def _bb_pt(d, df, timeperiod=20, nbdevup=2.0, **_): + r = _pta.bbands(df["close"], length=timeperiod, std=nbdevup) + return _first_col(r, "BBU_") + + +def _bb_ta(d, df, timeperiod=20, nbdevup=2.0, **_): + from ta.volatility import BollingerBands + + return _strip_nan( + BollingerBands(df["close"], window=timeperiod, window_dev=nbdevup) + .bollinger_hband() + .values + ) + + +def _bb_tu(d, df, timeperiod=20, nbdevup=2.0, **_): + lo, mi, up = _tl.bbands(_c64(d["close"]), period=timeperiod, stddev=nbdevup) + return _strip_nan(up) + + +def _bb_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.BBANDS(df, timeperiod)["BB_UPPER"].values) + + +def _atr_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.ATR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _atr_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.ATR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _atr_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.atr(df["high"], df["low"], df["close"], length=timeperiod).values + ) + + +def _atr_ta(d, df, timeperiod=14, **_): + from ta.volatility import AverageTrueRange + + return _strip_nan( + AverageTrueRange(df["high"], df["low"], df["close"], window=timeperiod) + .average_true_range() + .values + ) + + +def _atr_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.atr(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _atr_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.ATR(df, timeperiod).values) + + +def _natr_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.NATR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _natr_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.NATR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _natr_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.natr(df["high"], df["low"], df["close"], length=timeperiod).values + ) + + +def _natr_ta(d, df, **_): + return _empty() + + +_natr_ta._stub = True + + +def _natr_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.natr(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _natr_fi(d, df, **_): + return _empty() + + +_natr_fi._stub = True + + +def _trange_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TRANGE(d["high"], d["low"], d["close"])) + + +def _trange_tl(d, df, **_): + return _strip_nan(_talib.TRANGE(d["high"], d["low"], d["close"])) + + +def _trange_pt(d, df, **_): + r = _pta.true_range(df["high"], df["low"], df["close"]) + return _strip_nan(r.values) if r is not None else _empty() + + +def _trange_ta(d, df, **_): + return _empty() + + +_trange_ta._stub = True + + +def _trange_tu(d, df, **_): + return _strip_nan(_tl.tr(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]))) + + +def _trange_fi(d, df, **_): + return _strip_nan(_fi.TR(df).values) + + +def _stddev_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.STDDEV(d["close"], timeperiod=timeperiod)) + + +def _stddev_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.STDDEV(d["close"], timeperiod=timeperiod)) + + +def _stddev_pt(d, df, timeperiod=20, **_): + r = _pta.stdev(df["close"], length=timeperiod) + return _strip_nan(r.values) if r is not None else _empty() + + +def _stddev_ta(d, df, **_): + return _empty() + + +_stddev_ta._stub = True + + +def _stddev_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.stddev(_c64(d["close"]), period=timeperiod)) + + +def _stddev_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.MSD(df, timeperiod).values) + + +def _var_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.VAR(d["close"], timeperiod=timeperiod)) + + +def _var_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.VAR(d["close"], timeperiod=timeperiod)) + + +def _var_pt(d, df, timeperiod=20, **_): + r = _pta.variance(df["close"], length=timeperiod) + return _strip_nan(r.values) if r is not None else _empty() + + +def _var_ta(d, df, **_): + return _empty() + + +_var_ta._stub = True + + +def _var_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.var(_c64(d["close"]), period=timeperiod)) + + +def _var_fi(d, df, **_): + return _empty() + + +_var_fi._stub = True + + +def _sar_ft(d, df, acceleration=0.02, maximum=0.2, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.SAR(d["high"], d["low"], acceleration=acceleration, maximum=maximum) + ) + + +def _sar_tl(d, df, acceleration=0.02, maximum=0.2, **_): + return _strip_nan( + _talib.SAR(d["high"], d["low"], acceleration=acceleration, maximum=maximum) + ) + + +def _sar_pt(d, df, **_): + return _empty() + + +_sar_pt._stub = True + + +def _sar_ta(d, df, **_): + return _empty() + + +_sar_ta._stub = True + + +def _sar_tu(d, df, acceleration=0.02, maximum=0.2, **_): + return _strip_nan( + _tl.psar( + _c64(d["high"]), + _c64(d["low"]), + acceleration_factor_step=acceleration, + acceleration_factor_maximum=maximum, + ) + ) + + +def _sar_fi(d, df, **_): + return _empty() + + +_sar_fi._stub = True + + +def _kc_ft(d, df, timeperiod=20, **_): + import ferro_ta + + u, m, l = ferro_ta.KELTNER_CHANNELS( + d["high"], d["low"], d["close"], timeperiod=timeperiod + ) + return _strip_nan(u) + + +def _kc_tl(d, df, **_): + return _empty() + + +_kc_tl._stub = True + + +def _kc_pt(d, df, timeperiod=20, **_): + r = _pta.kc(df["high"], df["low"], df["close"], length=timeperiod) + if r is None: + return _empty() + col = next( + (c for c in r.columns if "UCe" in c or "UB" in c or c.endswith("U")), None + ) + return _strip_nan(r[col].values) if col else _first_col(r, "KC") + + +def _kc_ta(d, df, timeperiod=20, **_): + from ta.volatility import KeltnerChannel + + return _strip_nan( + KeltnerChannel(df["high"], df["low"], df["close"], window=timeperiod) + .keltner_channel_hband() + .values + ) + + +def _kc_tu(d, df, **_): + return _empty() + + +_kc_tu._stub = True + + +def _kc_fi(d, df, **_): + return _empty() + + +_kc_fi._stub = True + + +def _donchian_ft(d, df, timeperiod=20, **_): + import ferro_ta + + u, m, l = ferro_ta.DONCHIAN(d["high"], d["low"], timeperiod=timeperiod) + return _strip_nan(u) + + +def _donchian_tl(d, df, **_): + return _empty() + + +_donchian_tl._stub = True + + +def _donchian_pt(d, df, timeperiod=20, **_): + r = _pta.donchian( + df["high"], df["low"], lower_length=timeperiod, upper_length=timeperiod + ) + return _first_col(r, "DCU_") if r is not None else _empty() + + +def _donchian_ta(d, df, timeperiod=20, **_): + from ta.volatility import DonchianChannel + + return _strip_nan( + DonchianChannel(df["high"], df["low"], df["close"], window=timeperiod) + .donchian_channel_hband() + .values + ) + + +def _donchian_tu(d, df, **_): + return _empty() + + +_donchian_tu._stub = True + + +def _donchian_fi(d, df, **_): + return _empty() + + +_donchian_fi._stub = True + + +def _supertrend_ft(d, df, timeperiod=7, **_): + import ferro_ta + + st, dir_ = ferro_ta.SUPERTREND( + d["high"], d["low"], d["close"], timeperiod=timeperiod + ) + return _strip_nan(st) + + +def _supertrend_tl(d, df, **_): + return _empty() + + +_supertrend_tl._stub = True + + +def _supertrend_pt(d, df, timeperiod=7, **_): + r = _pta.supertrend(df["high"], df["low"], df["close"], length=timeperiod) + return _first_col(r, "SUPERT_") if r is not None else _empty() + + +def _supertrend_ta(d, df, **_): + return _empty() + + +_supertrend_ta._stub = True + + +def _supertrend_tu(d, df, **_): + return _empty() + + +_supertrend_tu._stub = True + + +def _supertrend_fi(d, df, **_): + return _empty() + + +_supertrend_fi._stub = True + + +def _chop_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CHOPPINESS_INDEX( + d["high"], d["low"], d["close"], timeperiod=timeperiod + ) + ) + + +def _chop_tl(d, df, **_): + return _empty() + + +_chop_tl._stub = True + + +def _chop_pt(d, df, timeperiod=14, **_): + r = _pta.chop(df["high"], df["low"], df["close"], length=timeperiod) + return _strip_nan(r.values) if r is not None else _empty() + + +def _chop_ta(d, df, **_): + return _empty() + + +_chop_ta._stub = True + + +def _chop_tu(d, df, **_): + return _empty() + + +_chop_tu._stub = True + + +def _chop_fi(d, df, **_): + return _empty() + + +_chop_fi._stub = True + + +# ============================================================ +# VOLUME +# ============================================================ +def _obv_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.OBV(d["close"], d["volume"])) + + +def _obv_tl(d, df, **_): + return _strip_nan(_talib.OBV(d["close"], d["volume"])) + + +def _obv_pt(d, df, **_): + return _strip_nan(_pta.obv(df["close"], df["volume"]).values) + + +def _obv_ta(d, df, **_): + from ta.volume import OnBalanceVolumeIndicator + + return _strip_nan( + OnBalanceVolumeIndicator(df["close"], df["volume"]).on_balance_volume().values + ) + + +def _obv_tu(d, df, **_): + return _strip_nan(_tl.obv(_c64(d["close"]), _c64(d["volume"]))) + + +def _obv_fi(d, df, **_): + return _strip_nan(_fi.OBV(df).values) + + +def _ad_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.AD(d["high"], d["low"], d["close"], d["volume"])) + + +def _ad_tl(d, df, **_): + return _strip_nan(_talib.AD(d["high"], d["low"], d["close"], d["volume"])) + + +def _ad_pt(d, df, **_): + return _strip_nan(_pta.ad(df["high"], df["low"], df["close"], df["volume"]).values) + + +def _ad_ta(d, df, **_): + from ta.volume import AccDistIndexIndicator + + return _strip_nan( + AccDistIndexIndicator(df["high"], df["low"], df["close"], df["volume"]) + .acc_dist_index() + .values + ) + + +def _ad_tu(d, df, **_): + return _strip_nan( + _tl.ad(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), _c64(d["volume"])) + ) + + +def _ad_fi(d, df, **_): + return _empty() + + +_ad_fi._stub = True + + +def _adosc_ft(d, df, fastperiod=3, slowperiod=10, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.ADOSC( + d["high"], + d["low"], + d["close"], + d["volume"], + fastperiod=fastperiod, + slowperiod=slowperiod, + ) + ) + + +def _adosc_tl(d, df, fastperiod=3, slowperiod=10, **_): + return _strip_nan( + _talib.ADOSC( + d["high"], + d["low"], + d["close"], + d["volume"], + fastperiod=fastperiod, + slowperiod=slowperiod, + ) + ) + + +def _adosc_pt(d, df, fastperiod=3, slowperiod=10, **_): + return _strip_nan( + _pta.adosc( + df["high"], + df["low"], + df["close"], + df["volume"], + fast=fastperiod, + slow=slowperiod, + ).values + ) + + +def _adosc_ta(d, df, **_): + return _empty() + + +_adosc_ta._stub = True + + +def _adosc_tu(d, df, fastperiod=3, slowperiod=10, **_): + return _strip_nan( + _tl.adosc( + _c64(d["high"]), + _c64(d["low"]), + _c64(d["close"]), + _c64(d["volume"]), + short_period=fastperiod, + long_period=slowperiod, + ) + ) + + +def _adosc_fi(d, df, **_): + return _empty() + + +_adosc_fi._stub = True + + +def _mfi_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.MFI( + d["high"], d["low"], d["close"], d["volume"], timeperiod=timeperiod + ) + ) + + +def _mfi_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.MFI(d["high"], d["low"], d["close"], d["volume"], timeperiod=timeperiod) + ) + + +def _mfi_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.mfi( + df["high"], df["low"], df["close"], df["volume"], length=timeperiod + ).values + ) + + +def _mfi_ta(d, df, timeperiod=14, **_): + from ta.volume import MFIIndicator + + return _strip_nan( + MFIIndicator( + df["high"], df["low"], df["close"], df["volume"], window=timeperiod + ) + .money_flow_index() + .values + ) + + +def _mfi_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.mfi( + _c64(d["high"]), + _c64(d["low"]), + _c64(d["close"]), + _c64(d["volume"]), + period=timeperiod, + ) + ) + + +def _mfi_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.MFI(df, timeperiod).values) + + +def _vwap_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.VWAP(d["high"], d["low"], d["close"], d["volume"])) + + +def _vwap_tl(d, df, **_): + return _empty() + + +_vwap_tl._stub = True + + +def _vwap_pt(d, df, **_): + r = _pta.vwap(df["high"], df["low"], df["close"], df["volume"]) + return _strip_nan(r.values) if r is not None else _empty() + + +def _vwap_ta(d, df, **_): + return _empty() + + +_vwap_ta._stub = True + + +def _vwap_tu(d, df, **_): + return _empty() + + +_vwap_tu._stub = True + + +def _vwap_fi(d, df, **_): + return _strip_nan(_fi.VWAP(df).values) + + +# ============================================================ +# PRICE TRANSFORM +# ============================================================ +def _avgprice_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.AVGPRICE(d["open"], d["high"], d["low"], d["close"])) + + +def _avgprice_tl(d, df, **_): + return _strip_nan(_talib.AVGPRICE(d["open"], d["high"], d["low"], d["close"])) + + +def _avgprice_pt(d, df, **_): + return _empty() + + +_avgprice_pt._stub = True + + +def _avgprice_ta(d, df, **_): + return _empty() + + +_avgprice_ta._stub = True + + +def _avgprice_tu(d, df, **_): + return _strip_nan( + _tl.avgprice(_c64(d["open"]), _c64(d["high"]), _c64(d["low"]), _c64(d["close"])) + ) + + +def _avgprice_fi(d, df, **_): + return _empty() + + +_avgprice_fi._stub = True + + +def _medprice_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.MEDPRICE(d["high"], d["low"])) + + +def _medprice_tl(d, df, **_): + return _strip_nan(_talib.MEDPRICE(d["high"], d["low"])) + + +def _medprice_pt(d, df, **_): + return _empty() + + +_medprice_pt._stub = True + + +def _medprice_ta(d, df, **_): + return _empty() + + +_medprice_ta._stub = True + + +def _medprice_tu(d, df, **_): + return _strip_nan(_tl.medprice(_c64(d["high"]), _c64(d["low"]))) + + +def _medprice_fi(d, df, **_): + return _empty() + + +_medprice_fi._stub = True + + +def _typprice_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TYPPRICE(d["high"], d["low"], d["close"])) + + +def _typprice_tl(d, df, **_): + return _strip_nan(_talib.TYPPRICE(d["high"], d["low"], d["close"])) + + +def _typprice_pt(d, df, **_): + return _empty() + + +_typprice_pt._stub = True + + +def _typprice_ta(d, df, **_): + return _empty() + + +_typprice_ta._stub = True + + +def _typprice_tu(d, df, **_): + return _strip_nan(_tl.typprice(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]))) + + +def _typprice_fi(d, df, **_): + return _empty() + + +_typprice_fi._stub = True + + +def _wclprice_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.WCLPRICE(d["high"], d["low"], d["close"])) + + +def _wclprice_tl(d, df, **_): + return _strip_nan(_talib.WCLPRICE(d["high"], d["low"], d["close"])) + + +def _wclprice_pt(d, df, **_): + return _empty() + + +_wclprice_pt._stub = True + + +def _wclprice_ta(d, df, **_): + return _empty() + + +_wclprice_ta._stub = True + + +def _wclprice_tu(d, df, **_): + return _strip_nan(_tl.wcprice(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]))) + + +def _wclprice_fi(d, df, **_): + return _empty() + + +_wclprice_fi._stub = True + + +# ============================================================ +# MATH +# ============================================================ +def _sqrt_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.SQRT(d["close"])) + + +def _sqrt_tl(d, df, **_): + return _strip_nan(_talib.SQRT(d["close"])) + + +def _sqrt_pt(d, df, **_): + return _empty() + + +_sqrt_pt._stub = True + + +def _sqrt_ta(d, df, **_): + return _empty() + + +_sqrt_ta._stub = True + + +def _sqrt_tu(d, df, **_): + return _strip_nan(_tl.sqrt(_c64(d["close"]))) + + +def _sqrt_fi(d, df, **_): + return _empty() + + +_sqrt_fi._stub = True + + +def _log10_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.LOG10(d["close"])) + + +def _log10_tl(d, df, **_): + return _strip_nan(_talib.LOG10(d["close"])) + + +def _log10_pt(d, df, **_): + return _empty() + + +_log10_pt._stub = True + + +def _log10_ta(d, df, **_): + return _empty() + + +_log10_ta._stub = True + + +def _log10_tu(d, df, **_): + return _strip_nan(_tl.log10(_c64(d["close"]))) + + +def _log10_fi(d, df, **_): + return _empty() + + +_log10_fi._stub = True + + +def _add_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.ADD(d["high"], d["low"])) + + +def _add_tl(d, df, **_): + return _strip_nan(_talib.ADD(d["high"], d["low"])) + + +def _add_pt(d, df, **_): + return _empty() + + +_add_pt._stub = True + + +def _add_ta(d, df, **_): + return _empty() + + +_add_ta._stub = True + + +def _add_tu(d, df, **_): + return _strip_nan(_tl.add(_c64(d["high"]), _c64(d["low"]))) + + +def _add_fi(d, df, **_): + return _empty() + + +_add_fi._stub = True + + +# ============================================================ +# STATISTICS +# ============================================================ +def _linearreg_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.LINEARREG(d["close"], timeperiod=timeperiod)) + + +def _linearreg_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.LINEARREG(d["close"], timeperiod=timeperiod)) + + +def _linearreg_pt(d, df, **_): + return _empty() + + +_linearreg_pt._stub = True + + +def _linearreg_ta(d, df, **_): + return _empty() + + +_linearreg_ta._stub = True + + +def _linearreg_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.linreg(_c64(d["close"]), period=timeperiod)) + + +def _linearreg_fi(d, df, **_): + return _empty() + + +_linearreg_fi._stub = True + + +def _linreg_slope_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.LINEARREG_SLOPE(d["close"], timeperiod=timeperiod)) + + +def _linreg_slope_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.LINEARREG_SLOPE(d["close"], timeperiod=timeperiod)) + + +def _linreg_slope_pt(d, df, **_): + return _empty() + + +_linreg_slope_pt._stub = True + + +def _linreg_slope_ta(d, df, **_): + return _empty() + + +_linreg_slope_ta._stub = True + + +def _linreg_slope_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.linregslope(_c64(d["close"]), period=timeperiod)) + + +def _linreg_slope_fi(d, df, **_): + return _empty() + + +_linreg_slope_fi._stub = True + + +def _correl_ft(d, df, timeperiod=30, **_): + import ferro_ta + + return _strip_nan(ferro_ta.CORREL(d["high"], d["low"], timeperiod=timeperiod)) + + +def _correl_tl(d, df, timeperiod=30, **_): + return _strip_nan(_talib.CORREL(d["high"], d["low"], timeperiod=timeperiod)) + + +def _correl_pt(d, df, **_): + return _empty() + + +_correl_pt._stub = True + + +def _correl_ta(d, df, **_): + return _empty() + + +_correl_ta._stub = True + + +def _correl_tu(d, df, **_): + return _empty() + + +_correl_tu._stub = True + + +def _correl_fi(d, df, **_): + return _empty() + + +_correl_fi._stub = True + + +def _beta_ft(d, df, timeperiod=5, **_): + import ferro_ta + + return _strip_nan(ferro_ta.BETA(d["high"], d["low"], timeperiod=timeperiod)) + + +def _beta_tl(d, df, timeperiod=5, **_): + return _strip_nan(_talib.BETA(d["high"], d["low"], timeperiod=timeperiod)) + + +def _beta_pt(d, df, **_): + return _empty() + + +_beta_pt._stub = True + + +def _beta_ta(d, df, **_): + return _empty() + + +_beta_ta._stub = True + + +def _beta_tu(d, df, **_): + return _empty() + + +_beta_tu._stub = True + + +def _beta_fi(d, df, **_): + return _empty() + + +_beta_fi._stub = True + + +# ============================================================ +# CYCLE +# ============================================================ +def _ht_dcperiod_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.HT_DCPERIOD(d["close"])) + + +def _ht_dcperiod_tl(d, df, **_): + return _strip_nan(_talib.HT_DCPERIOD(d["close"])) + + +def _ht_dcperiod_pt(d, df, **_): + return _empty() + + +_ht_dcperiod_pt._stub = True + + +def _ht_dcperiod_ta(d, df, **_): + return _empty() + + +_ht_dcperiod_ta._stub = True + + +def _ht_dcperiod_tu(d, df, **_): + return _empty() + + +_ht_dcperiod_tu._stub = True + + +def _ht_dcperiod_fi(d, df, **_): + return _empty() + + +_ht_dcperiod_fi._stub = True + + +def _ht_trendmode_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.HT_TRENDMODE(d["close"]).astype(float)) + + +def _ht_trendmode_tl(d, df, **_): + return _strip_nan(_talib.HT_TRENDMODE(d["close"]).astype(float)) + + +def _ht_trendmode_pt(d, df, **_): + return _empty() + + +_ht_trendmode_pt._stub = True + + +def _ht_trendmode_ta(d, df, **_): + return _empty() + + +_ht_trendmode_ta._stub = True + + +def _ht_trendmode_tu(d, df, **_): + return _empty() + + +_ht_trendmode_tu._stub = True + + +def _ht_trendmode_fi(d, df, **_): + return _empty() + + +_ht_trendmode_fi._stub = True + + +# ============================================================ +# CANDLESTICK PATTERNS +# ============================================================ +def _cdlengulfing_ft(d, df, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CDLENGULFING(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdlengulfing_tl(d, df, **_): + return _strip_nan( + _talib.CDLENGULFING(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdlengulfing_pt(d, df, **_): + return _empty() + + +_cdlengulfing_pt._stub = True + + +def _cdlengulfing_ta(d, df, **_): + return _empty() + + +_cdlengulfing_ta._stub = True + + +def _cdlengulfing_tu(d, df, **_): + return _empty() + + +_cdlengulfing_tu._stub = True + + +def _cdlengulfing_fi(d, df, **_): + return _empty() + + +_cdlengulfing_fi._stub = True + + +def _cdldoji_ft(d, df, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CDLDOJI(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdldoji_tl(d, df, **_): + return _strip_nan( + _talib.CDLDOJI(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdldoji_pt(d, df, **_): + return _empty() + + +_cdldoji_pt._stub = True + + +def _cdldoji_ta(d, df, **_): + return _empty() + + +_cdldoji_ta._stub = True + + +def _cdldoji_tu(d, df, **_): + return _empty() + + +_cdldoji_tu._stub = True + + +def _cdldoji_fi(d, df, **_): + return _empty() + + +_cdldoji_fi._stub = True + + +def _cdlhammer_ft(d, df, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CDLHAMMER(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdlhammer_tl(d, df, **_): + return _strip_nan( + _talib.CDLHAMMER(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdlhammer_pt(d, df, **_): + return _empty() + + +_cdlhammer_pt._stub = True + + +def _cdlhammer_ta(d, df, **_): + return _empty() + + +_cdlhammer_ta._stub = True + + +def _cdlhammer_tu(d, df, **_): + return _empty() + + +_cdlhammer_tu._stub = True + + +def _cdlhammer_fi(d, df, **_): + return _empty() + + +_cdlhammer_fi._stub = True + +# ============================================================ +# REGISTRY BUILD +# ============================================================ +REGISTRY: dict[tuple[str, Any], Any] = {} + + +def _reg(ind, ft, tl, pt, ta_, tu, fi): + """ + Register wrappers for a given indicator across all libraries. + + Wrappers marked ._stub = True (no-op return _empty()) are not registered, + so execute_indicator raises KeyError for unsupported (lib, ind). Speed + benchmarks then skip those pairs and the table shows N/A. + """ + for lib, fn in [ + ("ferro_ta", ft), + ("talib", tl), + ("pandas_ta", pt), + ("ta", ta_), + ("tulipy", tu), + ("finta", fi), + ]: + if getattr(fn, "_stub", False): + continue + REGISTRY[(lib, ind)] = fn + + +_reg("SMA", _sma_ft, _sma_tl, _sma_pt, _sma_ta, _sma_tu, _sma_fi) +_reg("EMA", _ema_ft, _ema_tl, _ema_pt, _ema_ta, _ema_tu, _ema_fi) +_reg("WMA", _wma_ft, _wma_tl, _wma_pt, _wma_ta, _wma_tu, _wma_fi) +_reg("DEMA", _dema_ft, _dema_tl, _dema_pt, _dema_ta, _dema_tu, _dema_fi) +_reg("TEMA", _tema_ft, _tema_tl, _tema_pt, _tema_ta, _tema_tu, _tema_fi) +_reg("T3", _t3_ft, _t3_tl, _t3_pt, _t3_ta, _t3_tu, _t3_fi) +_reg("TRIMA", _trima_ft, _trima_tl, _trima_pt, _trima_ta, _trima_tu, _trima_fi) +_reg("KAMA", _kama_ft, _kama_tl, _kama_pt, _kama_ta, _kama_tu, _kama_fi) +_reg("HULL_MA", _hma_ft, _hma_tl, _hma_pt, _hma_ta, _hma_tu, _hma_fi) +_reg("VWMA", _vwma_ft, _vwma_tl, _vwma_pt, _vwma_ta, _vwma_tu, _vwma_fi) +_reg( + "MIDPOINT", + _midpoint_ft, + _midpoint_tl, + _midpoint_pt, + _midpoint_ta, + _midpoint_tu, + _midpoint_fi, +) +_reg( + "MIDPRICE", + _midprice_ft, + _midprice_tl, + _midprice_pt, + _midprice_ta, + _midprice_tu, + _midprice_fi, +) +_reg("RSI", _rsi_ft, _rsi_tl, _rsi_pt, _rsi_ta, _rsi_tu, _rsi_fi) +_reg("MACD", _macd_ft, _macd_tl, _macd_pt, _macd_ta, _macd_tu, _macd_fi) +_reg("STOCH", _stoch_ft, _stoch_tl, _stoch_pt, _stoch_ta, _stoch_tu, _stoch_fi) +_reg("CCI", _cci_ft, _cci_tl, _cci_pt, _cci_ta, _cci_tu, _cci_fi) +_reg("WILLR", _willr_ft, _willr_tl, _willr_pt, _willr_ta, _willr_tu, _willr_fi) +_reg("AROON", _aroon_ft, _aroon_tl, _aroon_pt, _aroon_ta, _aroon_tu, _aroon_fi) +_reg( + "AROONOSC", + _aroonosc_ft, + _aroonosc_tl, + _aroonosc_pt, + _aroonosc_ta, + _aroonosc_tu, + _aroonosc_fi, +) +_reg("ADX", _adx_ft, _adx_tl, _adx_pt, _adx_ta, _adx_tu, _adx_fi) +_reg("MOM", _mom_ft, _mom_tl, _mom_pt, _mom_ta, _mom_tu, _mom_fi) +_reg("ROC", _roc_ft, _roc_tl, _roc_pt, _roc_ta, _roc_tu, _roc_fi) +_reg("CMO", _cmo_ft, _cmo_tl, _cmo_pt, _cmo_ta, _cmo_tu, _cmo_fi) +_reg("PPO", _ppo_ft, _ppo_tl, _ppo_pt, _ppo_ta, _ppo_tu, _ppo_fi) +_reg("TRIX", _trix_ft, _trix_tl, _trix_pt, _trix_ta, _trix_tu, _trix_fi) +_reg("TSF", _tsf_ft, _tsf_tl, _tsf_pt, _tsf_ta, _tsf_tu, _tsf_fi) +_reg("ULTOSC", _ultosc_ft, _ultosc_tl, _ultosc_pt, _ultosc_ta, _ultosc_tu, _ultosc_fi) +_reg("BOP", _bop_ft, _bop_tl, _bop_pt, _bop_ta, _bop_tu, _bop_fi) +_reg("PLUS_DI", _plusdi_ft, _plusdi_tl, _plusdi_pt, _plusdi_ta, _plusdi_tu, _plusdi_fi) +_reg( + "MINUS_DI", + _minusdi_ft, + _minusdi_tl, + _minusdi_pt, + _minusdi_ta, + _minusdi_tu, + _minusdi_fi, +) +_reg("BBANDS", _bb_ft, _bb_tl, _bb_pt, _bb_ta, _bb_tu, _bb_fi) +_reg("ATR", _atr_ft, _atr_tl, _atr_pt, _atr_ta, _atr_tu, _atr_fi) +_reg("NATR", _natr_ft, _natr_tl, _natr_pt, _natr_ta, _natr_tu, _natr_fi) +_reg("TRANGE", _trange_ft, _trange_tl, _trange_pt, _trange_ta, _trange_tu, _trange_fi) +_reg("STDDEV", _stddev_ft, _stddev_tl, _stddev_pt, _stddev_ta, _stddev_tu, _stddev_fi) +_reg("VAR", _var_ft, _var_tl, _var_pt, _var_ta, _var_tu, _var_fi) +_reg("SAR", _sar_ft, _sar_tl, _sar_pt, _sar_ta, _sar_tu, _sar_fi) +_reg("KELTNER_CHANNELS", _kc_ft, _kc_tl, _kc_pt, _kc_ta, _kc_tu, _kc_fi) +_reg( + "DONCHIAN", + _donchian_ft, + _donchian_tl, + _donchian_pt, + _donchian_ta, + _donchian_tu, + _donchian_fi, +) +_reg( + "SUPERTREND", + _supertrend_ft, + _supertrend_tl, + _supertrend_pt, + _supertrend_ta, + _supertrend_tu, + _supertrend_fi, +) +_reg("CHOPPINESS_INDEX", _chop_ft, _chop_tl, _chop_pt, _chop_ta, _chop_tu, _chop_fi) +_reg("OBV", _obv_ft, _obv_tl, _obv_pt, _obv_ta, _obv_tu, _obv_fi) +_reg("AD", _ad_ft, _ad_tl, _ad_pt, _ad_ta, _ad_tu, _ad_fi) +_reg("ADOSC", _adosc_ft, _adosc_tl, _adosc_pt, _adosc_ta, _adosc_tu, _adosc_fi) +_reg("MFI", _mfi_ft, _mfi_tl, _mfi_pt, _mfi_ta, _mfi_tu, _mfi_fi) +_reg("VWAP", _vwap_ft, _vwap_tl, _vwap_pt, _vwap_ta, _vwap_tu, _vwap_fi) +_reg( + "AVGPRICE", + _avgprice_ft, + _avgprice_tl, + _avgprice_pt, + _avgprice_ta, + _avgprice_tu, + _avgprice_fi, +) +_reg( + "MEDPRICE", + _medprice_ft, + _medprice_tl, + _medprice_pt, + _medprice_ta, + _medprice_tu, + _medprice_fi, +) +_reg( + "TYPPRICE", + _typprice_ft, + _typprice_tl, + _typprice_pt, + _typprice_ta, + _typprice_tu, + _typprice_fi, +) +_reg( + "WCLPRICE", + _wclprice_ft, + _wclprice_tl, + _wclprice_pt, + _wclprice_ta, + _wclprice_tu, + _wclprice_fi, +) +_reg("SQRT", _sqrt_ft, _sqrt_tl, _sqrt_pt, _sqrt_ta, _sqrt_tu, _sqrt_fi) +_reg("LOG10", _log10_ft, _log10_tl, _log10_pt, _log10_ta, _log10_tu, _log10_fi) +_reg("ADD", _add_ft, _add_tl, _add_pt, _add_ta, _add_tu, _add_fi) +_reg( + "LINEARREG", + _linearreg_ft, + _linearreg_tl, + _linearreg_pt, + _linearreg_ta, + _linearreg_tu, + _linearreg_fi, +) +_reg( + "LINEARREG_SLOPE", + _linreg_slope_ft, + _linreg_slope_tl, + _linreg_slope_pt, + _linreg_slope_ta, + _linreg_slope_tu, + _linreg_slope_fi, +) +_reg("CORREL", _correl_ft, _correl_tl, _correl_pt, _correl_ta, _correl_tu, _correl_fi) +_reg("BETA", _beta_ft, _beta_tl, _beta_pt, _beta_ta, _beta_tu, _beta_fi) +_reg( + "HT_DCPERIOD", + _ht_dcperiod_ft, + _ht_dcperiod_tl, + _ht_dcperiod_pt, + _ht_dcperiod_ta, + _ht_dcperiod_tu, + _ht_dcperiod_fi, +) +_reg( + "HT_TRENDMODE", + _ht_trendmode_ft, + _ht_trendmode_tl, + _ht_trendmode_pt, + _ht_trendmode_ta, + _ht_trendmode_tu, + _ht_trendmode_fi, +) +_reg( + "CDLENGULFING", + _cdlengulfing_ft, + _cdlengulfing_tl, + _cdlengulfing_pt, + _cdlengulfing_ta, + _cdlengulfing_tu, + _cdlengulfing_fi, +) +_reg( + "CDLDOJI", + _cdldoji_ft, + _cdldoji_tl, + _cdldoji_pt, + _cdldoji_ta, + _cdldoji_tu, + _cdldoji_fi, +) +_reg( + "CDLHAMMER", + _cdlhammer_ft, + _cdlhammer_tl, + _cdlhammer_pt, + _cdlhammer_ta, + _cdlhammer_tu, + _cdlhammer_fi, +) + +# ============================================================ +# METADATA +# ============================================================ +INDICATOR_DEFAULTS: dict[str, dict] = { + "SMA": {"timeperiod": 20}, + "EMA": {"timeperiod": 20}, + "WMA": {"timeperiod": 14}, + "DEMA": {"timeperiod": 20}, + "TEMA": {"timeperiod": 20}, + "T3": {"timeperiod": 5}, + "TRIMA": {"timeperiod": 20}, + "KAMA": {"timeperiod": 10}, + "HULL_MA": {"timeperiod": 16}, + "VWMA": {"timeperiod": 20}, + "MIDPOINT": {"timeperiod": 14}, + "MIDPRICE": {"timeperiod": 14}, + "RSI": {"timeperiod": 14}, + "MACD": {"fastperiod": 12, "slowperiod": 26, "signalperiod": 9}, + "STOCH": {"fastk_period": 14, "slowk_period": 3, "slowd_period": 3}, + "CCI": {"timeperiod": 14}, + "WILLR": {"timeperiod": 14}, + "AROON": {"timeperiod": 14}, + "AROONOSC": {"timeperiod": 14}, + "ADX": {"timeperiod": 14}, + "MOM": {"timeperiod": 10}, + "ROC": {"timeperiod": 10}, + "CMO": {"timeperiod": 14}, + "PPO": {"fastperiod": 12, "slowperiod": 26}, + "TRIX": {"timeperiod": 18}, + "TSF": {"timeperiod": 14}, + "ULTOSC": {"timeperiod1": 7, "timeperiod2": 14, "timeperiod3": 28}, + "BOP": {}, + "PLUS_DI": {"timeperiod": 14}, + "MINUS_DI": {"timeperiod": 14}, + "BBANDS": {"timeperiod": 20, "nbdevup": 2.0, "nbdevdn": 2.0}, + "ATR": {"timeperiod": 14}, + "NATR": {"timeperiod": 14}, + "TRANGE": {}, + "STDDEV": {"timeperiod": 20}, + "VAR": {"timeperiod": 20}, + "SAR": {"acceleration": 0.02, "maximum": 0.2}, + "KELTNER_CHANNELS": {"timeperiod": 20}, + "DONCHIAN": {"timeperiod": 20}, + "SUPERTREND": {"timeperiod": 7}, + "CHOPPINESS_INDEX": {"timeperiod": 14}, + "OBV": {}, + "AD": {}, + "ADOSC": {"fastperiod": 3, "slowperiod": 10}, + "MFI": {"timeperiod": 14}, + "VWAP": {}, + "AVGPRICE": {}, + "MEDPRICE": {}, + "TYPPRICE": {}, + "WCLPRICE": {}, + "SQRT": {}, + "LOG10": {}, + "ADD": {}, + "LINEARREG": {"timeperiod": 14}, + "LINEARREG_SLOPE": {"timeperiod": 14}, + "CORREL": {"timeperiod": 30}, + "BETA": {"timeperiod": 5}, + "HT_DCPERIOD": {}, + "HT_TRENDMODE": {}, + "CDLENGULFING": {}, + "CDLDOJI": {}, + "CDLHAMMER": {}, +} + +INDICATOR_NAMES = list(INDICATOR_DEFAULTS.keys()) +LIBRARY_NAMES = ["ferro_ta", "talib", "pandas_ta", "ta", "tulipy", "finta"] + +INDICATOR_CATEGORIES: dict[str, list[str]] = { + "Overlap": [ + "SMA", + "EMA", + "WMA", + "DEMA", + "TEMA", + "T3", + "TRIMA", + "KAMA", + "HULL_MA", + "VWMA", + "MIDPOINT", + "MIDPRICE", + ], + "Momentum": [ + "RSI", + "MACD", + "STOCH", + "CCI", + "WILLR", + "AROON", + "AROONOSC", + "ADX", + "MOM", + "ROC", + "CMO", + "PPO", + "TRIX", + "TSF", + "ULTOSC", + "BOP", + "PLUS_DI", + "MINUS_DI", + ], + "Volatility": [ + "BBANDS", + "ATR", + "NATR", + "TRANGE", + "STDDEV", + "VAR", + "SAR", + "KELTNER_CHANNELS", + "DONCHIAN", + "SUPERTREND", + "CHOPPINESS_INDEX", + ], + "Volume": ["OBV", "AD", "ADOSC", "MFI", "VWAP"], + "Price Transform": ["AVGPRICE", "MEDPRICE", "TYPPRICE", "WCLPRICE"], + "Math": ["SQRT", "LOG10", "ADD"], + "Statistics": ["LINEARREG", "LINEARREG_SLOPE", "CORREL", "BETA"], + "Cycle": ["HT_DCPERIOD", "HT_TRENDMODE"], + "Pattern": ["CDLENGULFING", "CDLDOJI", "CDLHAMMER"], +} + +# Cumulative: compare first-differences not absolute values +CUMULATIVE_INDICATORS = {"OBV", "AD", "ADOSC"} +# Binary output: use agreement rate not allclose +BINARY_INDICATORS = {"CDLENGULFING", "CDLDOJI", "CDLHAMMER", "HT_TRENDMODE"} + + +def execute_indicator(library, indicator, data, df=None, **kwargs): + """Run indicator from library on data dict, return 1-D float64 array.""" + if library not in available_libraries(): + raise KeyError(f"Library not available in this environment: {library!r}") + + key = (library, indicator) + if key not in REGISTRY: + raise KeyError(f"No wrapper for {key!r}") + if df is None: + from benchmarks.data_generator import get_pandas_ohlcv + + df = get_pandas_ohlcv(data) + params = {**INDICATOR_DEFAULTS.get(indicator, {}), **kwargs} + return REGISTRY[key](data, df, **params) diff --git a/ferro-ta-main/conda/meta.yaml b/ferro-ta-main/conda/meta.yaml new file mode 100644 index 0000000..6d903f1 --- /dev/null +++ b/ferro-ta-main/conda/meta.yaml @@ -0,0 +1,53 @@ +{% set name = "ferro-ta" %} +{% set version = "1.2.0" %} + +package: + name: {{ name|lower }} + version: {{ version }} + +source: + # Build from PyPI wheel (simplest approach; no Rust toolchain required). + # Replace with url/sha256 of the specific wheel for your platform or + # use `pip_install: true` to let conda-build fetch it. + pip_install: true + packages: + - ferro-ta=={{ version }} + +build: + number: 0 + # Use noarch: python only if wheels are already compiled. For source builds + # remove noarch and add the maturin build steps below. + noarch: python + script: | + {{ PYTHON }} -m pip install ferro-ta=={{ version }} --no-deps --ignore-installed -vv + +requirements: + host: + - python + - pip + run: + - python >=3.10 + - numpy >=1.20 + +test: + imports: + - ferro_ta + commands: + - python -c "from ferro_ta import SMA, RSI; import numpy as np; print(SMA(np.array([1.0,2.0,3.0,4.0,5.0]), timeperiod=3))" + +about: + home: https://github.com/pratikbhadane24/ferro-ta + license: MIT + license_family: MIT + summary: Rust-powered Python technical analysis library with a TA-Lib-compatible API + description: | + ferro-ta is a Rust-powered Python technical analysis library with a + TA-Lib-compatible API and pre-compiled wheels for the supported platforms. + It provides 155+ indicators via a Rust core and PyO3 bindings, with + optional pandas and streaming APIs. + doc_url: https://github.com/pratikbhadane24/ferro-ta + dev_url: https://github.com/pratikbhadane24/ferro-ta + +extra: + recipe-maintainers: + - pratikbhadane24 diff --git a/ferro-ta-main/crates/ferro_ta_core/Cargo.toml b/ferro-ta-main/crates/ferro_ta_core/Cargo.toml new file mode 100644 index 0000000..710beed --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "ferro_ta_core" +version = "1.2.0" +edition = "2021" +description = "Pure Rust core indicator library — no PyO3, no numpy dependency" +license = "MIT" +readme = "README.md" +repository = "https://github.com/pratikbhadane24/ferro-ta" +homepage = "https://github.com/pratikbhadane24/ferro-ta#readme" +documentation = "https://docs.rs/ferro_ta_core" +keywords = ["technical-analysis", "trading", "indicators", "finance", "ta-lib"] +categories = ["finance", "mathematics"] + +[lib] +name = "ferro_ta_core" +crate-type = ["lib"] + +[dependencies] +multiversion = { version = "0.8", 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"] } + +[[bench]] +name = "indicators" +harness = false + +[features] +# Runtime CPU-feature dispatch (multiversion). Default ON so `cargo add +# ferro_ta_core` and the published wheels get SIMD-accelerated reductions +# that adapt to the running CPU (baseline .. AVX-512 / NEON) WITHOUT pinning +# a target-cpu — one binary runs on any CPU of the target arch, with no +# illegal-instruction crashes on older chips. Disable with +# `--no-default-features` for a pure-scalar build. +default = ["simd"] +simd = ["dep:multiversion"] +serde = ["dep:serde", "dep:serde_json"] diff --git a/ferro-ta-main/crates/ferro_ta_core/README.md b/ferro-ta-main/crates/ferro_ta_core/README.md new file mode 100644 index 0000000..2add66e --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/README.md @@ -0,0 +1,87 @@ +# ferro_ta_core + +`ferro_ta_core` is the pure Rust indicator engine behind [`ferro-ta`](https://github.com/pratikbhadane24/ferro-ta). + +It provides allocation-friendly indicator functions over `&[f64]` slices without any +PyO3, NumPy, or Python runtime dependency, which makes it a good fit for: + +- Rust-native technical analysis workloads +- custom services and backtesting engines +- non-Python bindings (WASM, FFI) + +## Installation + +```toml +[dependencies] +ferro_ta_core = "1.2.0" +``` + +## Design + +- Pure functions over Rust slices +- No Python or NumPy dependency +- Shared core for the Python package and WASM bindings +- Output shape matches TA-Lib-style full-length series with `NaN` warm-up values where applicable + +## Modules + +| Module | Functions | Highlights | +|--------|-----------|------------| +| `overlap` | 20 | SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, BBANDS, MACD, MACDFIX, MACDEXT, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE, MA, MAVP, Hull MA | +| `momentum` | 26 | RSI, MOM, STOCH, STOCHF, ADX, ADXR, DX, +DI, -DI, +DM, -DM, ROC, WILLR, AROON, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC | +| `volatility` | 3 | ATR, NATR, TRANGE | +| `volume` | 4 | OBV, MFI, AD, ADOSC | +| `pattern` | 61 | All TA-Lib candlestick patterns (CDL2CROWS through CDLXSIDEGAP3METHODS) | +| `statistic` | 9 | STDDEV, VAR, LINEARREG, LINEARREG_SLOPE/INTERCEPT/ANGLE, TSF, BETA, CORREL | +| `math` | 24 | Rolling SUM/MAX/MIN/MAXINDEX/MININDEX, element-wise ADD/SUB/MULT/DIV, 15 transforms (trig, exp, log, sqrt, ceil, floor) | +| `price_transform` | 4 | AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE | +| `cycle` | 7 | Hilbert Transform: TRENDLINE, DCPERIOD, DCPHASE, PHASOR, SINE, TRENDMODE | +| `extended` | 10 | VWAP, VWMA, Supertrend, Donchian, Keltner, Ichimoku, Pivot Points, Hull MA, Chandelier Exit, Choppiness Index | +| `streaming` | 9 | Stateful bar-by-bar: SMA, EMA, RSI, ATR, BBands, MACD, Stoch, VWAP, Supertrend | +| `batch` | 8 | Vectorized multi-column: batch_sma/ema/rsi/atr/stoch/adx, run_close/hlc_indicators | +| `backtest` | 19 | Signal generators, close-only and OHLCV engines, walk-forward, Monte Carlo, performance metrics | +| `options` | 18 | Black-Scholes/Black-76 pricing, Greeks, implied volatility, IV rank/percentile/zscore, smile metrics, chain analytics | +| `futures` | 14 | Basis, annualized basis, carry, roll (weighted/back-adjusted/ratio), curve analysis, synthetic forward/spot | +| `portfolio` | 10 | Beta, correlation matrix, drawdown, relative strength, spread, ratio, z-score, portfolio volatility | +| `signals` | 4 | Rank values, compose rank, top/bottom N indices | +| `alerts` | 3 | Threshold crossings, cross detection, alert bar collection | +| `regime` | 4 | ADX regime, combined regime, CUSUM breaks, variance breaks | +| `aggregation` | 3 | Tick bars, volume bars, time bars from trade data | +| `resampling` | 2 | Volume bars, OHLCV aggregation by label | +| `chunked` | 4 | Trim overlap, stitch chunks, make chunk ranges, forward fill NaN | +| `crypto` | 3 | Funding cumulative PnL, continuous bar labels, session boundaries | + +## Example + +```rust +use ferro_ta_core::overlap; + +fn main() { + let close = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let sma = overlap::sma(&close, 3); + + assert!(sma[0].is_nan()); + assert!(sma[1].is_nan()); + assert!((sma[2] - 2.0).abs() < 1e-10); +} +``` + +## Relationship To `ferro-ta` + +The published Python package (`ferro-ta` on PyPI) wraps this crate with PyO3 bindings and adds NumPy conversion, pandas/polars wrappers, and higher-level Python tooling. The WASM package (`ferro-ta-wasm` on npm) also wraps this crate with full feature parity. + +If you only need Rust indicator functions, use `ferro_ta_core` directly. + +## Development + +From the repository root: + +```bash +cargo build -p ferro_ta_core +cargo test -p ferro_ta_core +cargo bench -p ferro_ta_core --no-run +``` + +## License + +MIT diff --git a/ferro-ta-main/crates/ferro_ta_core/benches/indicators.rs b/ferro-ta-main/crates/ferro_ta_core/benches/indicators.rs new file mode 100644 index 0000000..9c74666 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/benches/indicators.rs @@ -0,0 +1,212 @@ +//! Criterion benchmarks for ferro_ta_core — pure Rust indicator throughput. +//! +//! Run from repo root: cargo bench -p ferro_ta_core +//! Or: cd crates/ferro_ta_core && cargo bench +//! +//! Input sizes: 1k, 10k, 100k, and 1M bars for key indicators. +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use ferro_ta_core::{futures, momentum, options, overlap, volatility}; +use std::hint::black_box; + +fn synthetic_close(n: usize) -> Vec { + let mut v = Vec::with_capacity(n); + let mut price = 100.0_f64; + for i in 0..n { + price += ((i as f64 * 0.1).sin()) * 0.5; + v.push(price); + } + v +} + +fn synthetic_high_low_close(n: usize) -> (Vec, Vec, Vec) { + let close = synthetic_close(n); + 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) +} + +fn bench_sma(c: &mut Criterion) { + let mut group = c.benchmark_group("SMA"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let close = synthetic_close(size); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| overlap::sma(black_box(close), 14)) + }); + } + group.finish(); +} + +fn bench_ema(c: &mut Criterion) { + let mut group = c.benchmark_group("EMA"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let close = synthetic_close(size); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| overlap::ema(black_box(close), 14)) + }); + } + group.finish(); +} + +fn bench_rsi(c: &mut Criterion) { + let mut group = c.benchmark_group("RSI"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let close = synthetic_close(size); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| momentum::rsi(black_box(close), 14)) + }); + } + group.finish(); +} + +fn bench_atr(c: &mut Criterion) { + let mut group = c.benchmark_group("ATR"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let (high, low, close) = synthetic_high_low_close(size); + group.bench_with_input( + BenchmarkId::from_parameter(size), + &(high.clone(), low.clone(), close), + |b, (high, low, close)| { + b.iter(|| volatility::atr(black_box(high), black_box(low), black_box(close), 14)) + }, + ); + } + group.finish(); +} + +fn bench_bbands(c: &mut Criterion) { + let mut group = c.benchmark_group("BBANDS"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let close = synthetic_close(size); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| overlap::bbands(black_box(close), 20, 2.0, 2.0)) + }); + } + group.finish(); +} + +fn bench_bsm_price(c: &mut Criterion) { + let mut group = c.benchmark_group("BSM_PRICE"); + for size in [1_000_usize, 10_000, 100_000] { + let close = synthetic_close(size); + let strikes: Vec = close.iter().map(|_| 100.0).collect(); + let vols: Vec = close.iter().map(|_| 0.2).collect(); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| { + close + .iter() + .zip(strikes.iter()) + .zip(vols.iter()) + .map(|((&spot, &strike), &vol)| { + options::pricing::black_scholes_price( + black_box(spot), + black_box(strike), + black_box(0.02), + black_box(0.0), + black_box(0.5), + black_box(vol), + options::OptionKind::Call, + ) + }) + .collect::>() + }) + }); + } + group.finish(); +} + +fn bench_implied_volatility(c: &mut Criterion) { + let mut group = c.benchmark_group("IMPLIED_VOL"); + for size in [1_000_usize, 10_000] { + let prices: Vec = (0..size) + .map(|i| { + let spot = 90.0 + (i % 20) as f64; + options::pricing::black_scholes_price( + spot, + 100.0, + 0.02, + 0.0, + 0.5, + 0.2, + options::OptionKind::Call, + ) + }) + .collect(); + group.bench_with_input(BenchmarkId::from_parameter(size), &prices, |b, prices| { + b.iter(|| { + prices + .iter() + .enumerate() + .map(|(i, &price)| { + options::iv::implied_volatility( + options::OptionContract { + model: options::PricingModel::BlackScholes, + underlying: black_box(90.0 + (i % 20) as f64), + strike: black_box(100.0), + rate: black_box(0.02), + carry: black_box(0.0), + time_to_expiry: black_box(0.5), + kind: options::OptionKind::Call, + }, + black_box(price), + options::IvSolverConfig { + initial_guess: black_box(0.25), + tolerance: black_box(1e-8), + max_iterations: black_box(100), + }, + ) + }) + .collect::>() + }) + }); + } + group.finish(); +} + +fn bench_smile_metrics(c: &mut Criterion) { + let mut group = c.benchmark_group("SMILE_METRICS"); + let strikes: Vec = (0..41).map(|i| 80.0 + i as f64).collect(); + let vols: Vec = strikes + .iter() + .map(|&k| 0.18 + ((k - 100.0).abs() / 100.0) * 0.15) + .collect(); + group.bench_function("single_chain", |b| { + b.iter(|| { + options::surface::smile_metrics( + black_box(&strikes), + black_box(&vols), + black_box(100.0), + black_box(0.02), + black_box(0.0), + black_box(0.5), + options::PricingModel::BlackScholes, + ) + }) + }); + group.finish(); +} + +fn bench_curve_summary(c: &mut Criterion) { + let mut group = c.benchmark_group("FUTURES_CURVE"); + let tenors = vec![0.1, 0.25, 0.5, 0.75, 1.0]; + let prices = vec![101.0, 101.8, 102.7, 103.4, 104.1]; + group.bench_function("curve_summary", |b| { + b.iter(|| { + futures::curve::curve_summary(black_box(100.0), black_box(&tenors), black_box(&prices)) + }) + }); + group.finish(); +} + +criterion_group!( + benches, + bench_sma, + bench_ema, + bench_rsi, + bench_atr, + bench_bbands, + bench_bsm_price, + bench_implied_volatility, + bench_smile_metrics, + bench_curve_summary +); +criterion_main!(benches); diff --git a/ferro-ta-main/crates/ferro_ta_core/src/aggregation.rs b/ferro-ta-main/crates/ferro_ta_core/src/aggregation.rs new file mode 100644 index 0000000..43c7464 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/aggregation.rs @@ -0,0 +1,340 @@ +//! 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/ferro-ta-main/crates/ferro_ta_core/src/alerts.rs b/ferro-ta-main/crates/ferro_ta_core/src/alerts.rs new file mode 100644 index 0000000..62d3475 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/alerts.rs @@ -0,0 +1,126 @@ +//! 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 && prev <= level && curr > level) + || (direction == -1 && prev >= level && curr < level) + { + out[i] = 1; + } + } + out +} + +/// Detect cross-over / cross-under events between two series. +/// +/// Returns `Vec`: `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/ferro-ta-main/crates/ferro_ta_core/src/attribution.rs b/ferro-ta-main/crates/ferro_ta_core/src/attribution.rs new file mode 100644 index 0000000..d7536b9 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/attribution.rs @@ -0,0 +1,333 @@ +//! 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/ferro-ta-main/crates/ferro_ta_core/src/backtest.rs b/ferro-ta-main/crates/ferro_ta_core/src/backtest.rs new file mode 100644 index 0000000..7b9c33f --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/backtest.rs @@ -0,0 +1,2123 @@ +//! 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. +#[allow(clippy::too_many_arguments)] +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(); + #[allow(clippy::needless_range_loop)] + if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 { + for i in 0..n_bars { + // 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]; + #[allow(clippy::needless_range_loop)] + 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/ferro-ta-main/crates/ferro_ta_core/src/batch.rs b/ferro-ta-main/crates/ferro_ta_core/src/batch.rs new file mode 100644 index 0000000..24f55c5 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/batch.rs @@ -0,0 +1,641 @@ +//! 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)`. +#[allow(clippy::type_complexity)] +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/ferro-ta-main/crates/ferro_ta_core/src/chunked.rs b/ferro-ta-main/crates/ferro_ta_core/src/chunked.rs new file mode 100644 index 0000000..9743626 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/commission.rs b/ferro-ta-main/crates/ferro_ta_core/src/commission.rs new file mode 100644 index 0000000..5003e73 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/crypto.rs b/ferro-ta-main/crates/ferro_ta_core/src/crypto.rs new file mode 100644 index 0000000..21e714a --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/currency.rs b/ferro-ta-main/crates/ferro_ta_core/src/currency.rs new file mode 100644 index 0000000..6343786 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/cycle.rs b/ferro-ta-main/crates/ferro_ta_core/src/cycle.rs new file mode 100644 index 0000000..fd5c845 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/extended.rs b/ferro-ta-main/crates/ferro_ta_core/src/extended.rs new file mode 100644 index 0000000..5706800 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/extended.rs @@ -0,0 +1,962 @@ +//! 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 || 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. +#[allow(clippy::type_complexity)] +pub fn ichimoku( + high: &[f64], + low: &[f64], + close: &[f64], + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, +) -> (Vec, 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). +#[allow(clippy::type_complexity)] +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/ferro-ta-main/crates/ferro_ta_core/src/futures/basis.rs b/ferro-ta-main/crates/ferro_ta_core/src/futures/basis.rs new file mode 100644 index 0000000..f7e4ade --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/futures/basis.rs @@ -0,0 +1,55 @@ +//! Basis and carry analytics. + +/// Futures basis: futures - spot. +pub fn basis(spot: f64, future: f64) -> f64 { + if !spot.is_finite() || !future.is_finite() { + f64::NAN + } else { + future - spot + } +} + +/// Annualized simple basis return. +pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> f64 { + if !spot.is_finite() + || !future.is_finite() + || !time_to_expiry.is_finite() + || spot <= 0.0 + || time_to_expiry <= 0.0 + { + return f64::NAN; + } + (future / spot - 1.0) / time_to_expiry +} + +/// Implied continuously compounded carry rate. +pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> f64 { + if !spot.is_finite() + || !future.is_finite() + || !time_to_expiry.is_finite() + || spot <= 0.0 + || future <= 0.0 + || time_to_expiry <= 0.0 + { + return f64::NAN; + } + (future / spot).ln() / time_to_expiry +} + +/// Carry spread relative to the risk-free rate. +pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> f64 { + implied_carry_rate(spot, future, time_to_expiry) - rate +} + +#[cfg(test)] +mod tests { + use super::{annualized_basis, basis, carry_spread, implied_carry_rate}; + + #[test] + fn basis_helpers_work() { + assert_eq!(basis(100.0, 103.0), 3.0); + assert!(annualized_basis(100.0, 103.0, 0.25) > 0.0); + assert!(implied_carry_rate(100.0, 103.0, 0.25) > 0.0); + assert!(carry_spread(100.0, 103.0, 0.02, 0.25).is_finite()); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/futures/curve.rs b/ferro-ta-main/crates/ferro_ta_core/src/futures/curve.rs new file mode 100644 index 0000000..773d7e4 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/futures/curve.rs @@ -0,0 +1,83 @@ +//! Futures curve and term-structure analytics. + +use super::basis; + +/// Curve summary metrics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CurveSummary { + pub front_basis: f64, + pub average_basis: f64, + pub slope: f64, + pub is_contango: bool, +} + +fn regression_slope(xs: &[f64], ys: &[f64]) -> f64 { + if xs.len() != ys.len() || xs.len() < 2 { + return f64::NAN; + } + let n = xs.len() as f64; + let mean_x = xs.iter().sum::() / n; + let mean_y = ys.iter().sum::() / n; + let mut cov = 0.0; + let mut var = 0.0; + for (&x, &y) in xs.iter().zip(ys.iter()) { + cov += (x - mean_x) * (y - mean_y); + var += (x - mean_x) * (x - mean_x); + } + if var == 0.0 { + f64::NAN + } else { + cov / var + } +} + +/// Calendar spreads between adjacent contracts. +pub fn calendar_spreads(futures_prices: &[f64]) -> Vec { + futures_prices.windows(2).map(|w| w[1] - w[0]).collect() +} + +/// Curve slope across tenor buckets. +pub fn curve_slope(tenors: &[f64], futures_prices: &[f64]) -> f64 { + regression_slope(tenors, futures_prices) +} + +/// Summary statistics for a forward curve. +pub fn curve_summary(spot: f64, tenors: &[f64], futures_prices: &[f64]) -> CurveSummary { + if futures_prices.is_empty() || tenors.len() != futures_prices.len() { + return CurveSummary { + front_basis: f64::NAN, + average_basis: f64::NAN, + slope: f64::NAN, + is_contango: false, + }; + } + let bases: Vec = futures_prices + .iter() + .map(|&price| basis::basis(spot, price)) + .collect(); + let average_basis = bases.iter().sum::() / bases.len() as f64; + let is_contango = futures_prices.windows(2).all(|w| w[1] >= w[0]); + CurveSummary { + front_basis: basis::basis(spot, futures_prices[0]), + average_basis, + slope: curve_slope(tenors, futures_prices), + is_contango, + } +} + +#[cfg(test)] +mod tests { + use super::{calendar_spreads, curve_slope, curve_summary}; + + #[test] + fn calendar_spreads_are_correct() { + assert_eq!(calendar_spreads(&[100.0, 101.0, 103.0]), vec![1.0, 2.0]); + } + + #[test] + fn curve_summary_detects_contango() { + let summary = curve_summary(100.0, &[0.1, 0.5, 1.0], &[101.0, 102.0, 104.0]); + assert!(summary.is_contango); + assert!(curve_slope(&[0.1, 0.5, 1.0], &[101.0, 102.0, 104.0]) > 0.0); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/futures/mod.rs b/ferro-ta-main/crates/ferro_ta_core/src/futures/mod.rs new file mode 100644 index 0000000..60fc1f6 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/futures/mod.rs @@ -0,0 +1,6 @@ +//! Futures analytics core. + +pub mod basis; +pub mod curve; +pub mod roll; +pub mod synthetic; diff --git a/ferro-ta-main/crates/ferro_ta_core/src/futures/roll.rs b/ferro-ta-main/crates/ferro_ta_core/src/futures/roll.rs new file mode 100644 index 0000000..6e08207 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/futures/roll.rs @@ -0,0 +1,109 @@ +//! Continuous futures roll helpers. + +/// Weighted stitching using next-contract weights in [0, 1]. +pub fn weighted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec { + if front.len() != next.len() || front.len() != next_weights.len() { + return Vec::new(); + } + front + .iter() + .zip(next.iter()) + .zip(next_weights.iter()) + .map(|((&f, &n), &w)| f * (1.0 - w) + n * w) + .collect() +} + +fn roll_index(weights: &[f64]) -> Option { + if weights.is_empty() { + return None; + } + weights + .iter() + .enumerate() + .find(|(_, w)| **w >= 0.5) + .map(|(idx, _)| idx) + .or_else(|| weights.iter().position(|w| *w > 0.0)) + .or(Some(weights.len() - 1)) +} + +/// Back-adjusted continuous series using the roll date implied by the weights. +pub fn back_adjusted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec { + if front.len() != next.len() || front.len() != next_weights.len() || front.is_empty() { + return Vec::new(); + } + let idx = roll_index(next_weights).unwrap_or(front.len() - 1); + let gap = next[idx] - front[idx]; + front + .iter() + .enumerate() + .map(|(i, &value)| if i < idx { value + gap } else { next[i] }) + .collect() +} + +/// Ratio-adjusted continuous series using the roll date implied by the weights. +pub fn ratio_adjusted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec { + if front.len() != next.len() || front.len() != next_weights.len() || front.is_empty() { + return Vec::new(); + } + let idx = roll_index(next_weights).unwrap_or(front.len() - 1); + let ratio = if front[idx] == 0.0 { + 1.0 + } else { + next[idx] / front[idx] + }; + front + .iter() + .enumerate() + .map(|(i, &value)| if i < idx { value * ratio } else { next[i] }) + .collect() +} + +/// Annualized roll yield from front and next prices. +pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> f64 { + if !front_price.is_finite() + || !next_price.is_finite() + || !time_to_expiry.is_finite() + || front_price <= 0.0 + || time_to_expiry <= 0.0 + { + return f64::NAN; + } + (next_price / front_price - 1.0) / time_to_expiry +} + +#[cfg(test)] +mod tests { + use super::{ + back_adjusted_continuous, ratio_adjusted_continuous, roll_yield, weighted_continuous, + }; + + #[test] + fn weighted_roll_blends_contracts() { + let out = weighted_continuous(&[100.0, 101.0], &[102.0, 103.0], &[0.0, 1.0]); + assert_eq!(out, vec![100.0, 103.0]); + } + + #[test] + fn adjusted_rolls_return_full_series() { + let weights = [0.0, 0.25, 0.75, 1.0]; + assert_eq!( + back_adjusted_continuous( + &[100.0, 101.0, 102.0, 103.0], + &[101.0, 102.0, 103.0, 104.0], + &weights + ) + .len(), + 4 + ); + assert_eq!( + ratio_adjusted_continuous( + &[100.0, 101.0, 102.0, 103.0], + &[101.0, 102.0, 103.0, 104.0], + &weights + ) + .len(), + 4 + ); + assert!(roll_yield(100.0, 102.0, 30.0 / 365.0).is_finite()); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/futures/synthetic.rs b/ferro-ta-main/crates/ferro_ta_core/src/futures/synthetic.rs new file mode 100644 index 0000000..02a9acd --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/futures/synthetic.rs @@ -0,0 +1,78 @@ +//! Synthetic futures helpers built from put-call parity. + +/// Synthetic forward price from call/put parity. +pub fn synthetic_forward( + call_price: f64, + put_price: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, +) -> f64 { + if !call_price.is_finite() + || !put_price.is_finite() + || !strike.is_finite() + || !rate.is_finite() + || !time_to_expiry.is_finite() + || strike <= 0.0 + || time_to_expiry < 0.0 + { + return f64::NAN; + } + (call_price - put_price) * (rate * time_to_expiry).exp() + strike +} + +/// Synthetic spot price implied by call/put parity with continuous carry. +pub fn synthetic_spot( + call_price: f64, + put_price: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, +) -> f64 { + if !call_price.is_finite() + || !put_price.is_finite() + || !strike.is_finite() + || !rate.is_finite() + || !carry.is_finite() + || !time_to_expiry.is_finite() + || strike <= 0.0 + || time_to_expiry < 0.0 + { + return f64::NAN; + } + (call_price - put_price + strike * (-rate * time_to_expiry).exp()) + * (carry * time_to_expiry).exp() +} + +/// Put-call parity residual. Zero means the inputs are parity-consistent. +pub fn parity_gap( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, +) -> f64 { + call_price + - put_price + - (spot * (-carry * time_to_expiry).exp() - strike * (-rate * time_to_expiry).exp()) +} + +#[cfg(test)] +mod tests { + use super::{parity_gap, synthetic_forward}; + + #[test] + fn synthetic_forward_is_consistent() { + let forward = synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5); + assert!(forward > 100.0); + } + + #[test] + fn parity_gap_zero_when_consistent() { + let gap = parity_gap(10.45, 5.57, 100.0, 100.0, 0.05, 0.0, 1.0); + assert!(gap.abs() < 0.05); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/lib.rs b/ferro-ta-main/crates/ferro_ta_core/src/lib.rs new file mode 100644 index 0000000..2de6ea3 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/lib.rs @@ -0,0 +1,59 @@ +#![forbid(unsafe_code)] + +/*! +ferro_ta_core — Pure Rust indicator library. + +This crate contains all indicator implementations as pure functions operating +on `&[f64]` slices and returning `Vec`. It has **no dependency on PyO3 +or numpy** so it can be used from any Rust project, or compiled to WASM / +Node.js via napi-rs without dragging in Python bindings. + +The Python wheel (`ferro_ta` PyPI package) is built from a thin binding crate +that calls into this core and converts NumPy arrays to/from Rust slices. + +# Two-layer architecture + +The root crate (`ferro_ta`) contains PyO3 `#[pyfunction]` wrappers that convert +numpy arrays to `&[f64]` and delegate to this core crate. + +# Usage (Rust) + +```rust +use ferro_ta_core::overlap; + +let close = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]; +let sma = overlap::sma(&close, 3); +assert!(sma[0].is_nan()); +assert!((sma[2] - 2.0).abs() < 1e-10); +``` +*/ + +pub mod 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; +/// Runtime-dispatched SIMD reduction primitives (internal). +pub(crate) mod simd; +pub mod statistic; +pub mod streaming; +pub mod volatility; +pub mod volume; diff --git a/ferro-ta-main/crates/ferro_ta_core/src/math.rs b/ferro-ta-main/crates/ferro_ta_core/src/math.rs new file mode 100644 index 0000000..a8877ca --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/math.rs @@ -0,0 +1,217 @@ +//! Math utilities. + +use std::collections::VecDeque; + +/// 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]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let mut win: f64 = real[..timeperiod].iter().sum(); + result[timeperiod - 1] = win; + for i in timeperiod..n { + win += real[i] - real[i - timeperiod]; + result[i] = win; + } + result +} + +/// 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) +} + +/// 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) +} + +/// Compute the sliding maximum over `timeperiod` bars in O(n) time. +/// +/// 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]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let mut dq: VecDeque = VecDeque::new(); + for i in 0..n { + // Remove indices outside the window + while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) { + dq.pop_front(); + } + // Maintain decreasing deque + while dq.back().map(|&j| real[j] <= real[i]).unwrap_or(false) { + dq.pop_back(); + } + dq.push_back(i); + if i + 1 >= timeperiod { + result[i] = real[*dq.front().unwrap()]; + } + } + result +} + +/// Compute the sliding minimum over `timeperiod` bars in O(n) time. +/// +/// 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]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let mut dq: VecDeque = VecDeque::new(); + for i in 0..n { + // Remove indices outside the window + while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) { + dq.pop_front(); + } + // Maintain increasing deque + while dq.back().map(|&j| real[j] >= real[i]).unwrap_or(false) { + dq.pop_back(); + } + dq.push_back(i); + if i + 1 >= timeperiod { + result[i] = real[*dq.front().unwrap()]; + } + } + result +} + +// --------------------------------------------------------------------------- +// Element-wise arithmetic operators +// --------------------------------------------------------------------------- + +/// Element-wise addition of two arrays. +pub fn add(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b.iter()).map(|(&x, &y)| x + y).collect() +} + +/// Element-wise subtraction of two arrays. +pub fn sub(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b.iter()).map(|(&x, &y)| x - y).collect() +} + +/// Element-wise multiplication of two arrays. +pub fn mult(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b.iter()).map(|(&x, &y)| x * y).collect() +} + +/// Element-wise division of two arrays (NaN where b=0). +pub fn div(a: &[f64], b: &[f64]) -> Vec { + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| if y != 0.0 { x / y } else { f64::NAN }) + .collect() +} + +// --------------------------------------------------------------------------- +// Element-wise math transforms +// --------------------------------------------------------------------------- + +macro_rules! unary_transform { + ($name:ident, $method:ident) => { + pub fn $name(real: &[f64]) -> Vec { + real.iter().map(|&x| x.$method()).collect() + } + }; +} + +unary_transform!(math_acos, acos); +unary_transform!(math_asin, asin); +unary_transform!(math_atan, atan); +unary_transform!(math_ceil, ceil); +unary_transform!(math_cos, cos); +unary_transform!(math_cosh, cosh); +unary_transform!(math_exp, exp); +unary_transform!(math_floor, floor); +unary_transform!(math_ln, ln); +unary_transform!(math_log10, log10); +unary_transform!(math_sin, sin); +unary_transform!(math_sinh, sinh); +unary_transform!(math_sqrt, sqrt); +unary_transform!(math_tan, tan); +unary_transform!(math_tanh, tanh); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sum_basic() { + let v = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let r = sum(&v, 3); + assert!(r[0].is_nan()); + assert!((r[2] - 6.0).abs() < 1e-10); + assert!((r[4] - 12.0).abs() < 1e-10); + } + + #[test] + fn max_basic() { + let v = vec![3.0, 1.0, 4.0, 1.0, 5.0]; + let r = max(&v, 3); + assert!((r[2] - 4.0).abs() < 1e-10); + assert!((r[4] - 5.0).abs() < 1e-10); + } + + #[test] + fn sliding_max_matches_naive() { + let v = vec![3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]; + let naive = max(&v, 3); + let fast = sliding_max(&v, 3); + for i in 0..v.len() { + assert_eq!(naive[i].is_nan(), fast[i].is_nan()); + if !naive[i].is_nan() { + assert!((naive[i] - fast[i]).abs() < 1e-10); + } + } + } + + #[test] + fn sliding_min_matches_naive() { + let v = vec![3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]; + let naive = min(&v, 3); + let fast = sliding_min(&v, 3); + for i in 0..v.len() { + assert_eq!(naive[i].is_nan(), fast[i].is_nan()); + if !naive[i].is_nan() { + assert!((naive[i] - fast[i]).abs() < 1e-10); + } + } + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/math_ops.rs b/ferro-ta-main/crates/ferro_ta_core/src/math_ops.rs new file mode 100644 index 0000000..99800e7 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/momentum.rs b/ferro-ta-main/crates/ferro_ta_core/src/momentum.rs new file mode 100644 index 0000000..b70ac88 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/momentum.rs @@ -0,0 +1,925 @@ +//! Momentum indicators. + +/// Compute the Relative Strength Index (RSI). +/// +/// 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]; + if n <= timeperiod || timeperiod < 1 { + return result; + } + let mut avg_gain = 0.0_f64; + let mut avg_loss = 0.0_f64; + for i in 1..=timeperiod { + let diff = close[i] - close[i - 1]; + let abs_diff = diff.abs(); + avg_gain += (diff + abs_diff) * 0.5; + avg_loss += (abs_diff - diff) * 0.5; + } + avg_gain /= timeperiod as f64; + avg_loss /= timeperiod as f64; + let p = timeperiod as f64; + let rs = if avg_loss == 0.0 { + f64::MAX + } else { + avg_gain / avg_loss + }; + result[timeperiod] = 100.0 - 100.0 / (1.0 + rs); + for i in (timeperiod + 1)..n { + let diff = close[i] - close[i - 1]; + let abs_diff = diff.abs(); + let gain = (diff + abs_diff) * 0.5; + let loss = (abs_diff - diff) * 0.5; + avg_gain = (avg_gain * (p - 1.0) + gain) / p; + avg_loss = (avg_loss * (p - 1.0) + loss) / p; + let rs = if avg_loss == 0.0 { + f64::MAX + } else { + avg_gain / avg_loss + }; + result[i] = 100.0 - 100.0 / (1.0 + rs); + } + result +} + +/// 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]; + if timeperiod < 1 { + return result; + } + for i in timeperiod..n { + result[i] = close[i] - close[i - timeperiod]; + } + result +} + +/// Compute the Stochastic Oscillator (TA-Lib compatible). +/// +/// Returns `(slow_k, slow_d)`, both in the range `[0, 100]`. +/// - Fast %K = 100 * (close - lowest low) / (highest high - lowest low) +/// - Slow %K = SMA(fast %K, `slowk_period`) +/// - Slow %D = SMA(slow %K, `slowd_period`) +/// +/// 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], + close: &[f64], + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, +) -> (Vec, Vec) { + let n = high.len(); + let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]); + if n == 0 || fastk_period < 1 || slowk_period < 1 || slowd_period < 1 { + return nan_pair(); + } + if n < fastk_period { + return nan_pair(); + } + + let mut slowk = vec![f64::NAN; n]; + let mut slowd = vec![f64::NAN; n]; + + // Fused pass: compute fast %K inline with sliding max/min. + // For typical small windows (5-14), inline scan beats VecDeque overhead. + let fastk_start = fastk_period - 1; + let fk_len = n - fastk_start; + let mut fastk_valid = vec![0.0_f64; fk_len]; + + for i in fastk_start..n { + // Inline sliding max(high) and min(low) over [i - fastk_period + 1 .. i]. + let win_start = i + 1 - fastk_period; + let mut hh = high[win_start]; + let mut ll = low[win_start]; + for j in (win_start + 1)..=i { + let h = high[j]; + let l = low[j]; + if h > hh { + hh = h; + } + if l < ll { + ll = l; + } + } + let range = hh - ll; + fastk_valid[i - fastk_start] = if range != 0.0 { + 100.0 * (close[i] - ll) / range + } else { + 0.0 + }; + } + + // 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). + let slowk_valid_start = fastk_start + slowk_period - 1; + let slowd_valid_start = slowk_valid_start + slowd_period - 1; + + if slowk_valid_start < n { + let slowk_valid_slice = &slowk[slowk_valid_start..]; + crate::overlap::sma_into( + slowk_valid_slice, + slowd_period, + &mut slowd, + slowk_valid_start, + ); + } + + // TA-Lib pads BOTH slowk and slowd with NaNs up to the point where both are valid. + if slowd_valid_start < n { + for v in slowk.iter_mut().take(slowd_valid_start) { + *v = f64::NAN; + } + } else { + for v in slowk.iter_mut().take(n) { + *v = f64::NAN; + } + } + + (slowk, slowd) +} + +// --------------------------------------------------------------------------- +// ADX family +// --------------------------------------------------------------------------- + +/// Return type for ADX inner (pdm_s, mdm_s, plus_di, minus_di, dx, adx). +type AdxInnerOutput = (Vec, Vec, Vec, Vec, Vec, Vec); + +/// Fused inner function for ADX-family indicators. +/// Returns a tuple of (pdm_s, mdm_s, plus_di, minus_di, dx, adx). +fn adx_inner(high: &[f64], low: &[f64], close: &[f64], period: usize) -> AdxInnerOutput { + let n = high.len(); + let mut b_pdm = vec![f64::NAN; n]; + let mut b_mdm = vec![f64::NAN; n]; + let mut b_pdi = vec![f64::NAN; n]; + let mut b_mdi = vec![f64::NAN; n]; + let mut b_dx = vec![f64::NAN; n]; + let mut b_adx = vec![f64::NAN; n]; + + if n < period || period < 1 || n < 2 { + return (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx); + } + + let m = n - 1; + let mut tr = vec![0.0_f64; m]; + let mut pdm = vec![0.0_f64; m]; + let mut mdm = vec![0.0_f64; m]; + + for i in 0..m { + let j = i + 1; + let h_diff = high[j] - high[i]; + let l_diff = low[i] - low[j]; + let hl = high[j] - low[j]; + let hpc = (high[j] - close[i]).abs(); + let lpc = (low[j] - close[i]).abs(); + tr[i] = hl.max(hpc).max(lpc); + pdm[i] = if h_diff > l_diff && h_diff > 0.0 { + h_diff + } else { + 0.0 + }; + mdm[i] = if l_diff > h_diff && l_diff > 0.0 { + l_diff + } else { + 0.0 + }; + } + + if m < period { + return (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx); + } + + let mut tr_s = tr[..period].iter().sum::(); + let mut pdm_s = pdm[..period].iter().sum::(); + let mut mdm_s = mdm[..period].iter().sum::(); + + // Initial seeded values at index `period` + b_pdm[period] = pdm_s; + b_mdm[period] = mdm_s; + if tr_s != 0.0 { + b_pdi[period] = 100.0 * pdm_s / tr_s; + b_mdi[period] = 100.0 * mdm_s / tr_s; + let s = b_pdi[period] + b_mdi[period]; + b_dx[period] = if s != 0.0 { + 100.0 * (b_pdi[period] - b_mdi[period]).abs() / s + } else { + 0.0 + }; + } + + let decay = (period - 1) as f64 / period as f64; + for i in period..m { + tr_s = tr_s * decay + tr[i]; + pdm_s = pdm_s * decay + pdm[i]; + mdm_s = mdm_s * decay + mdm[i]; + + b_pdm[i + 1] = pdm_s; + b_mdm[i + 1] = mdm_s; + if tr_s != 0.0 { + b_pdi[i + 1] = 100.0 * pdm_s / tr_s; + b_mdi[i + 1] = 100.0 * mdm_s / tr_s; + let s = b_pdi[i + 1] + b_mdi[i + 1]; + b_dx[i + 1] = if s != 0.0 { + 100.0 * (b_pdi[i + 1] - b_mdi[i + 1]).abs() / s + } else { + 0.0 + }; + } + } + + // Wilder smooth DX to get ADX + let adx_start = period + period - 1; + if n > adx_start { + let mut dx_sum = 0.0; + let mut valid_dx = true; + for v in b_dx.iter().skip(period).take(period) { + if v.is_nan() { + valid_dx = false; + break; + } + dx_sum += v; + } + if valid_dx { + let mut adx_s = dx_sum / period as f64; + b_adx[adx_start] = adx_s; + let alpha = 1.0 / period as f64; + for i in adx_start + 1..n { + adx_s = adx_s + alpha * (b_dx[i] - adx_s); + b_adx[i] = adx_s; + } + } + } + + (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx) +} + +/// 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 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 +} + +/// 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 (_, mdm) = dm_only_inner(high, low, timeperiod); + mdm +} + +/// Compute the Plus Directional Indicator (+DI), Wilder smoothed. +/// +/// `+DI = 100 * smoothed(+DM) / smoothed(TR)`. Returns values in `[0, 100]`. +/// The first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn plus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let (_, _, pdi, _, _, _) = adx_inner(high, low, close, timeperiod); + pdi +} + +/// Compute the Minus Directional Indicator (-DI), Wilder smoothed. +/// +/// `-DI = 100 * smoothed(-DM) / smoothed(TR)`. Returns values in `[0, 100]`. +/// The first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn minus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let (_, _, _, mdi, _, _) = adx_inner(high, low, close, timeperiod); + mdi +} + +/// Compute the Directional Movement Index (DX). +/// +/// `DX = 100 * |+DI - -DI| / (+DI + -DI)`. Returns values in `[0, 100]`. +/// The first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn dx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let (_, _, _, _, dx_vals, _) = adx_inner(high, low, close, timeperiod); + dx_vals +} + +/// Compute the Average Directional Movement Index (ADX). +/// +/// ADX is Wilder's smoothing of DX, measuring trend strength regardless of +/// direction. Returns values in `[0, 100]`. The first `2 * timeperiod - 1` +/// values are `NaN` (DX warmup + ADX smoothing warmup). +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period (typically 14). +pub fn adx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod); + adx_vals +} + +/// Compute the ADX Rating (ADXR). +/// +/// `ADXR[i] = (ADX[i] + ADX[i - timeperiod]) / 2`. Smooths ADX further +/// by averaging current ADX with its value `timeperiod` bars ago. +/// Returns values in `[0, 100]`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period (typically 14). +pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + // Reuse adx_all to compute ADX once, then derive ADXR from it + let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + if !adx_vals[i].is_nan() && !adx_vals[i - timeperiod].is_nan() { + result[i] = (adx_vals[i] + adx_vals[i - timeperiod]) / 2.0; + } + } + result +} + +// --------------------------------------------------------------------------- +// Rate of Change variants +// --------------------------------------------------------------------------- + +/// Rate of Change: `(close[i] - close[i-p]) / close[i-p] * 100`. +pub fn roc(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + for i in timeperiod..n { + let prev = close[i - timeperiod]; + if prev != 0.0 { + result[i] = (close[i] - prev) / prev * 100.0; + } + } + result +} + +/// Rate of Change Percentage: `(close[i] - close[i-p]) / close[i-p]`. +pub fn rocp(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + for i in timeperiod..n { + let prev = close[i - timeperiod]; + if prev != 0.0 { + result[i] = (close[i] - prev) / prev; + } + } + result +} + +/// Rate of Change Ratio: `close[i] / close[i-p]`. +pub fn rocr(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + for i in timeperiod..n { + let prev = close[i - timeperiod]; + if prev != 0.0 { + result[i] = close[i] / prev; + } + } + result +} + +/// Rate of Change Ratio x 100: `close[i] / close[i-p] * 100`. +pub fn rocr100(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + for i in timeperiod..n { + let prev = close[i - timeperiod]; + if prev != 0.0 { + result[i] = close[i] / prev * 100.0; + } + } + result +} + +// --------------------------------------------------------------------------- +// Williams %R +// --------------------------------------------------------------------------- + +/// Williams %R: `-100 * (HH - close) / (HH - LL)` over the window. +/// Returns values in `[-100, 0]`. +pub fn willr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let start = i + 1 - timeperiod; + let mut highest = f64::NEG_INFINITY; + let mut lowest = f64::INFINITY; + for j in start..=i { + if high[j] > highest { + highest = high[j]; + } + if low[j] < lowest { + lowest = low[j]; + } + } + let range = highest - lowest; + result[i] = if range != 0.0 { + -100.0 * (highest - close[i]) / range + } else { + -50.0 + }; + } + result +} + +// --------------------------------------------------------------------------- +// Aroon +// --------------------------------------------------------------------------- + +/// Aroon indicator. Returns `(aroon_down, aroon_up)`. +pub fn aroon(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec, Vec) { + let n = high.len(); + let mut aroon_down = vec![f64::NAN; n]; + let mut aroon_up = vec![f64::NAN; n]; + if timeperiod == 0 || n <= timeperiod { + return (aroon_down, aroon_up); + } + let period_f = timeperiod as f64; + let window_size = timeperiod + 1; + for i in timeperiod..n { + let start = i + 1 - window_size; + let mut max_val = high[start]; + let mut min_val = low[start]; + let mut max_idx = 0usize; + let mut min_idx = 0usize; + for j in 0..window_size { + if high[start + j] >= max_val { + max_val = high[start + j]; + max_idx = j; + } + if low[start + j] <= min_val { + min_val = low[start + j]; + min_idx = j; + } + } + aroon_up[i] = 100.0 * (max_idx as f64) / period_f; + aroon_down[i] = 100.0 * (min_idx as f64) / period_f; + } + (aroon_down, aroon_up) +} + +/// Aroon Oscillator: `aroon_up - aroon_down`. +pub fn aroonosc(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { + let (down, up) = aroon(high, low, timeperiod); + up.iter() + .zip(down.iter()) + .map(|(&u, &d)| { + if u.is_nan() || d.is_nan() { + f64::NAN + } else { + u - d + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// CCI +// --------------------------------------------------------------------------- + +/// Commodity Channel Index: `(tp - SMA(tp)) / (0.015 * MAD)`. +pub fn cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let tp: Vec = high + .iter() + .zip(low.iter()) + .zip(close.iter()) + .map(|((&h, &l), &c)| (h + l + c) / 3.0) + .collect(); + for i in (timeperiod - 1)..n { + let window = &tp[(i + 1 - timeperiod)..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let mad: f64 = window.iter().map(|&x| (x - mean).abs()).sum::() / timeperiod as f64; + result[i] = if mad != 0.0 { + (tp[i] - mean) / (0.015 * mad) + } else { + 0.0 + }; + } + result +} + +// --------------------------------------------------------------------------- +// BOP +// --------------------------------------------------------------------------- + +/// Balance of Power: `(close - open) / (high - low)`. +pub fn bop(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + open.iter() + .zip(high.iter()) + .zip(low.iter()) + .zip(close.iter()) + .map(|(((&o, &h), &l), &c)| { + let range = h - l; + if range != 0.0 { + (c - o) / range + } else { + 0.0 + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Stochastic RSI +// --------------------------------------------------------------------------- + +/// Stochastic RSI. Returns `(fastk, fastd)`. +pub fn stochrsi( + close: &[f64], + timeperiod: usize, + fastk_period: usize, + fastd_period: usize, +) -> (Vec, Vec) { + let n = close.len(); + let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]); + if timeperiod == 0 || fastk_period == 0 || fastd_period == 0 { + return nan_pair(); + } + + let rsi_vals = rsi(close, timeperiod); + let rsi_warmup = timeperiod; + let k_warmup = rsi_warmup + fastk_period - 1; + let d_warmup = k_warmup + fastd_period - 1; + + let mut fastk = vec![f64::NAN; n]; + let mut fastd = vec![f64::NAN; n]; + + for i in k_warmup..n { + if rsi_vals[i].is_nan() { + continue; + } + let start = i + 1 - fastk_period; + if (start..=i).any(|j| rsi_vals[j].is_nan()) { + continue; + } + let mx = rsi_vals[start..=i] + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let mn = rsi_vals[start..=i] + .iter() + .cloned() + .fold(f64::INFINITY, f64::min); + fastk[i] = if mx != mn { + 100.0 * (rsi_vals[i] - mn) / (mx - mn) + } else { + 50.0 + }; + } + + for i in d_warmup..n { + let start = i + 1 - fastd_period; + let window = &fastk[start..=i]; + if window.iter().all(|v| !v.is_nan()) { + fastd[i] = window.iter().sum::() / fastd_period as f64; + } + } + (fastk, fastd) +} + +// --------------------------------------------------------------------------- +// APO / PPO +// --------------------------------------------------------------------------- + +/// Absolute Price Oscillator: `fast EMA - slow EMA`. +pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if fastperiod == 0 || slowperiod == 0 || fastperiod >= slowperiod { + return result; + } + let fast = crate::overlap::ema(close, fastperiod); + let slow = crate::overlap::ema(close, slowperiod); + let warmup = slowperiod - 1; + for i in warmup..n { + if !fast[i].is_nan() && !slow[i].is_nan() { + result[i] = fast[i] - slow[i]; + } + } + result +} + +/// Percentage Price Oscillator: `(fast EMA - slow EMA) / slow EMA * 100`. +/// Returns `(ppo_line, signal_line, histogram)`. +pub fn ppo( + close: &[f64], + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]); + if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod { + return nan3(); + } + let fast = crate::overlap::ema(close, fastperiod); + let slow = crate::overlap::ema(close, slowperiod); + let warmup = slowperiod - 1; + + let mut ppo_line = vec![f64::NAN; n]; + for i in warmup..n { + if !fast[i].is_nan() && !slow[i].is_nan() && slow[i] != 0.0 { + ppo_line[i] = (fast[i] - slow[i]) / slow[i] * 100.0; + } + } + + // Signal line = EMA of PPO line (only over valid values) + let signal = crate::overlap::ema(&ppo_line, signalperiod); + let mut signal_line = vec![f64::NAN; n]; + let mut hist = vec![f64::NAN; n]; + let sig_warmup = warmup + signalperiod - 1; + for i in sig_warmup..n { + if !ppo_line[i].is_nan() && !signal[i].is_nan() { + signal_line[i] = signal[i]; + hist[i] = ppo_line[i] - signal[i]; + } + } + (ppo_line, signal_line, hist) +} + +// --------------------------------------------------------------------------- +// CMO +// --------------------------------------------------------------------------- + +/// Chande Momentum Oscillator: `100 * (sum_gains - sum_losses) / (sum_gains + sum_losses)`. +pub fn cmo(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod + 1 { + return result; + } + let changes: Vec = close.windows(2).map(|w| w[1] - w[0]).collect(); + for i in timeperiod..n { + let mut ups = 0.0_f64; + let mut downs = 0.0_f64; + for ch in &changes[(i - timeperiod)..i] { + if *ch > 0.0 { + ups += ch; + } else { + downs -= ch; + } + } + let denom = ups + downs; + result[i] = if denom != 0.0 { + 100.0 * (ups - downs) / denom + } else { + 0.0 + }; + } + result +} + +// --------------------------------------------------------------------------- +// TRIX +// --------------------------------------------------------------------------- + +/// TRIX: 1-period rate of change of triple-smoothed EMA. +pub fn trix(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + let warmup = 3 * (timeperiod - 1); + + // Triple EMA: EMA(EMA(EMA(close))) + let ema1 = crate::overlap::ema(close, timeperiod); + let ema2 = crate::overlap::ema(&ema1, timeperiod); + let ema3 = crate::overlap::ema(&ema2, timeperiod); + + for i in (warmup + 1)..n { + let prev = ema3[i - 1]; + if !ema3[i].is_nan() && !prev.is_nan() && prev != 0.0 { + result[i] = (ema3[i] - prev) / prev * 100.0; + } + } + result +} + +// --------------------------------------------------------------------------- +// Ultimate Oscillator +// --------------------------------------------------------------------------- + +/// Ultimate Oscillator: weighted average of buying pressure over three periods. +pub fn ultosc( + high: &[f64], + low: &[f64], + close: &[f64], + timeperiod1: usize, + timeperiod2: usize, + timeperiod3: usize, +) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod1 == 0 || timeperiod2 == 0 || timeperiod3 == 0 || n < 2 { + return result; + } + let max_period = timeperiod1.max(timeperiod2).max(timeperiod3); + if n <= max_period { + return result; + } + + let mut bp = vec![0.0_f64; n]; + let mut tr = vec![0.0_f64; n]; + for i in 1..n { + let true_low = low[i].min(close[i - 1]); + let true_high = high[i].max(close[i - 1]); + bp[i] = close[i] - true_low; + tr[i] = true_high - true_low; + } + + for i in max_period..n { + let avg = |period: usize| -> f64 { + let sum_bp: f64 = bp[(i + 1 - period)..=i].iter().sum(); + let sum_tr: f64 = tr[(i + 1 - period)..=i].iter().sum(); + if sum_tr != 0.0 { + sum_bp / sum_tr + } else { + 0.0 + } + }; + result[i] = + 100.0 * (4.0 * avg(timeperiod1) + 2.0 * avg(timeperiod2) + avg(timeperiod3)) / 7.0; + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rsi_range() { + let prices: Vec = (1..=50).map(|i| i as f64).collect(); + let result = rsi(&prices, 14); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0 && *v <= 100.0); + } + } + + #[test] + fn mom_basic() { + let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = mom(&prices, 2); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 2.0).abs() < 1e-10); + } + + #[test] + fn stoch_basic() { + let high = vec![10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5]; + let low = vec![9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5]; + let close = vec![9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0]; + let (slowk, slowd) = stoch(&high, &low, &close, 3, 3, 3); + // Check that valid values are in [0, 100] + for v in slowk.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0 && *v <= 100.0, "slowk out of range: {v}"); + } + for v in slowd.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0 && *v <= 100.0, "slowd out of range: {v}"); + } + } + + #[test] + fn adx_nonnegative() { + let h: Vec = (1..=50).map(|i| i as f64 + 1.0).collect(); + let l: Vec = (1..=50).map(|i| i as f64).collect(); + let c: Vec = (1..=50).map(|i| i as f64 + 0.5).collect(); + let result = adx(&h, &l, &c, 14); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0); + } + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/american.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/american.rs new file mode 100644 index 0000000..6ae41cb --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/american.rs @@ -0,0 +1,410 @@ +//! American option pricing via the Barone-Adesi-Whaley (1987) quadratic approximation. + +use super::normal::cdf; +use super::pricing::black_scholes_price; +use super::OptionKind; + +fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool { + !spot.is_finite() + || !strike.is_finite() + || !time_to_expiry.is_finite() + || !volatility.is_finite() + || spot <= 0.0 + || strike <= 0.0 + || time_to_expiry < 0.0 + || volatility < 0.0 +} + +/// Compute d1 for BSM given spot S* (used inside the Newton-Raphson loop). +fn d1_fn(s: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64, volatility: f64) -> f64 { + let sigma_sqrt_t = volatility * time_to_expiry.sqrt(); + ((s / strike).ln() + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t +} + +/// Find the critical spot price S* for American call early exercise using Newton-Raphson. +/// +/// S* satisfies: C(S*) - (S* - K) = (S*/q2) * (1 - e^{-q*T} * N(d1(S*))) +/// Rearranged as F(S*) = 0: +/// F(x) = C(x) - (x - K) - (x/q2) * (1 - carry_discount * N(d1(x))) = 0 +fn find_critical_call( + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + q2: f64, +) -> f64 { + let carry_discount = (-carry * time_to_expiry).exp(); + + // Initial guess: S* ≈ K * q2 / (q2 - 1), clamped to be above strike + let mut s = if q2 > 1.0 { + strike * q2 / (q2 - 1.0) + } else { + // q2 <= 1 means the denominator is small/negative; fall back to a safe value + strike * 2.0 + }; + // Ensure starting guess is positive + if s <= 0.0 { + s = strike * 1.5; + } + + for _ in 0..50 { + let c = black_scholes_price( + s, + strike, + rate, + carry, + time_to_expiry, + volatility, + OptionKind::Call, + ); + let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility); + let nd1 = cdf(d1); + let lhs = c - (s - strike); + let rhs = (s / q2) * (1.0 - carry_discount * nd1); + let f = lhs - rhs; + + // Derivative of F with respect to s: + // dC/ds = e^{-q*T} * N(d1) (BSM delta for call) + // d(s - K)/ds = 1 + // d(rhs)/ds = (1/q2) * (1 - carry_discount * N(d1)) + // + (s/q2) * (-carry_discount * phi(d1) / (s * vol * sqrt(T))) + // = (1/q2) * (1 - carry_discount * N(d1)) - carry_discount * phi(d1) / (q2 * vol * sqrt(T)) + let sigma_sqrt_t = volatility * time_to_expiry.sqrt(); + let phi_d1 = super::normal::pdf(d1); + let d_lhs_ds = carry_discount * nd1 - 1.0; + let d_rhs_ds = (1.0 / q2) * (1.0 - carry_discount * nd1) + - carry_discount * phi_d1 / (q2 * sigma_sqrt_t); + let df = d_lhs_ds - d_rhs_ds; + + if df.abs() < 1e-14 { + break; + } + let step = f / df; + s -= step; + // Keep s positive + if s <= 0.0 { + s = strike * 0.1; + } + if step.abs() < 1e-8 { + break; + } + } + s +} + +/// Find the critical spot price S** for American put early exercise using Newton-Raphson. +/// +/// S** satisfies: P(S**) - (K - S**) = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**))) +/// F(x) = P(x) - (K - x) + (x/q1) * (1 - carry_discount * N(-d1(x))) = 0 +fn find_critical_put( + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + q1: f64, +) -> f64 { + let carry_discount = (-carry * time_to_expiry).exp(); + + // Initial guess for put: S** ≈ K * q1 / (q1 - 1) + // q1 is negative, so q1 - 1 < 0, and the guess should be below strike. + let mut s = if (q1 - 1.0).abs() > 1e-10 { + strike * q1 / (q1 - 1.0) + } else { + strike * 0.5 + }; + if s <= 0.0 || s >= strike { + s = strike * 0.5; + } + + for _ in 0..50 { + let p = black_scholes_price( + s, + strike, + rate, + carry, + time_to_expiry, + volatility, + OptionKind::Put, + ); + let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility); + let n_neg_d1 = cdf(-d1); + let lhs = p - (strike - s); + // rhs = -(s/q1) * (1 - carry_discount * N(-d1)) + let rhs = -(s / q1) * (1.0 - carry_discount * n_neg_d1); + let f = lhs - rhs; + + // Derivative: + // dP/ds = -e^{-q*T} * N(-d1) (BSM delta for put = e^{-q*T}*(N(d1)-1)) + // d(K - s)/ds = -1 so d(lhs)/ds = dP/ds - (-1) = dP/ds + 1 + // d(rhs)/ds = -(1/q1)*(1 - carry_discount*N(-d1)) + // + -(s/q1)*carry_discount*phi(d1)/(s*vol*sqrt(T)) [since d(N(-d1))/ds = -phi(d1)*dd1/ds] + // = -(1/q1)*(1 - carry_discount*N(-d1)) + // - carry_discount*phi(d1)/(q1*vol*sqrt(T)) + let sigma_sqrt_t = volatility * time_to_expiry.sqrt(); + let phi_d1 = super::normal::pdf(d1); + let d_lhs_ds = -carry_discount * n_neg_d1 + 1.0; + let d_rhs_ds = -(1.0 / q1) * (1.0 - carry_discount * n_neg_d1) + - carry_discount * phi_d1 / (q1 * sigma_sqrt_t); + let df = d_lhs_ds - d_rhs_ds; + + if df.abs() < 1e-14 { + break; + } + let step = f / df; + s -= step; + if s <= 0.0 { + s = strike * 0.01; + } + if s >= strike { + s = strike * 0.99; + } + if step.abs() < 1e-8 { + break; + } + } + s +} + +/// American option price using the Barone-Adesi-Whaley (1987) quadratic approximation. +/// +/// # Parameters +/// - `spot`: current underlying price +/// - `strike`: option strike price +/// - `rate`: risk-free rate (annualized, decimal) +/// - `carry`: continuous dividend yield / carry rate +/// - `time_to_expiry`: time to expiry in years +/// - `volatility`: implied vol (annualized, decimal) +/// - `kind`: call or put +pub fn american_price_baw( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> f64 { + if invalid_inputs(spot, strike, time_to_expiry, volatility) + || !rate.is_finite() + || !carry.is_finite() + { + return f64::NAN; + } + + // At expiry: immediate exercise value + if time_to_expiry == 0.0 { + return match kind { + OptionKind::Call => (spot - strike).max(0.0), + OptionKind::Put => (strike - spot).max(0.0), + }; + } + + // At zero vol: deterministic — exercise if ITM + if volatility == 0.0 { + return match kind { + OptionKind::Call => (spot - strike).max(0.0), + OptionKind::Put => (strike - spot).max(0.0), + }; + } + + let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind); + + match kind { + OptionKind::Call => { + // No early exercise premium when there are no dividends (carry == 0 means q==0 + // in BSM parameterisation where carry = q). + if carry <= 0.0 { + return european; + } + + let sigma2 = volatility * volatility; + let m = 2.0 * rate / sigma2; + let n = 2.0 * (rate - carry) / sigma2; + let h = 1.0 - (-rate * time_to_expiry).exp(); + + if h.abs() < 1e-14 { + return european; + } + + let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h; + if discriminant < 0.0 { + return european; + } + + let q2 = (-(n - 1.0) + discriminant.sqrt()) / 2.0; + + // Find critical price S* + let s_star = find_critical_call(strike, rate, carry, time_to_expiry, volatility, q2); + + if s_star <= strike { + // Degenerate critical price; fall back to European + return european; + } + + // A2 = (S*/q2) * (1 - e^{-q*T} * N(d1(S*))) + let carry_discount = (-carry * time_to_expiry).exp(); + let d1_star = d1_fn(s_star, strike, rate, carry, time_to_expiry, volatility); + let a2 = (s_star / q2) * (1.0 - carry_discount * cdf(d1_star)); + + if spot >= s_star { + // Immediate exercise is optimal + (spot - strike).max(0.0) + } else { + (european + a2 * (spot / s_star).powf(q2)).max(european) + } + } + + OptionKind::Put => { + // No early exercise when rate == 0 (no time value of money) + if rate <= 0.0 { + return european; + } + + let sigma2 = volatility * volatility; + let m = 2.0 * rate / sigma2; + let n = 2.0 * (rate - carry) / sigma2; + let h = 1.0 - (-rate * time_to_expiry).exp(); + + if h.abs() < 1e-14 { + return european; + } + + let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h; + if discriminant < 0.0 { + return european; + } + + let q1 = (-(n - 1.0) - discriminant.sqrt()) / 2.0; + + // Find critical price S** + let s_star_star = + find_critical_put(strike, rate, carry, time_to_expiry, volatility, q1); + + if s_star_star <= 0.0 || s_star_star >= strike { + return european; + } + + // A1 = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**))) + let carry_discount = (-carry * time_to_expiry).exp(); + let d1_star = d1_fn(s_star_star, strike, rate, carry, time_to_expiry, volatility); + let a1 = -(s_star_star / q1) * (1.0 - carry_discount * cdf(-d1_star)); + + if spot <= s_star_star { + // Immediate exercise is optimal + (strike - spot).max(0.0) + } else { + (european + a1 * (spot / s_star_star).powf(q1)).max(european) + } + } + } +} + +/// Early exercise premium = american_price - european_bsm_price. +/// +/// Always non-negative for valid inputs. +pub fn early_exercise_premium( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> f64 { + let american = american_price_baw(spot, strike, rate, carry, time_to_expiry, volatility, kind); + let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind); + if american.is_nan() || european.is_nan() { + return f64::NAN; + } + (american - european).max(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::OptionKind; + + #[test] + fn american_call_gte_european_call() { + let european = crate::options::pricing::black_scholes_price( + 100.0, + 100.0, + 0.05, + 0.03, + 1.0, + 0.2, + OptionKind::Call, + ); + let american = american_price_baw(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call); + assert!(american >= european - 1e-10); + } + + #[test] + fn american_put_gte_european_put() { + let european = crate::options::pricing::black_scholes_price( + 100.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Put, + ); + let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put); + assert!(american >= european - 1e-10); + } + + #[test] + fn early_exercise_premium_nonneg() { + let prem = early_exercise_premium(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call); + assert!(prem >= 0.0); + } + + #[test] + fn american_call_no_dividends_equals_european() { + // With no dividends (carry == 0), no early exercise is optimal for calls + let european = crate::options::pricing::black_scholes_price( + 100.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Call, + ); + let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + assert!((american - european).abs() < 1e-10); + } + + #[test] + fn american_price_returns_nan_for_invalid() { + let price = american_price_baw(-1.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + assert!(price.is_nan()); + } + + #[test] + fn american_price_at_expiry_is_intrinsic() { + let call = american_price_baw(110.0, 100.0, 0.05, 0.03, 0.0, 0.2, OptionKind::Call); + assert!((call - 10.0).abs() < 1e-10); + let put = american_price_baw(90.0, 100.0, 0.05, 0.0, 0.0, 0.2, OptionKind::Put); + assert!((put - 10.0).abs() < 1e-10); + } + + #[test] + fn american_put_itm_has_positive_premium() { + // Deep ITM put with high rate should have meaningful early exercise premium + let prem = early_exercise_premium(80.0, 100.0, 0.10, 0.0, 1.0, 0.2, OptionKind::Put); + assert!(prem >= 0.0); + } + + #[test] + fn american_prices_are_finite_for_valid_inputs() { + let call = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Call); + let put = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Put); + assert!(call.is_finite()); + assert!(put.is_finite()); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/chain.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/chain.rs new file mode 100644 index 0000000..a716ae4 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/chain.rs @@ -0,0 +1,162 @@ +//! Option chain analytics helpers. + +use super::greeks::model_greeks; +use super::{ChainGreeksContext, OptionContract, OptionEvaluation, OptionKind}; + +/// Return the index of the strike closest to the reference price. +pub fn atm_index(strikes: &[f64], reference_price: f64) -> Option { + if strikes.is_empty() || !reference_price.is_finite() { + return None; + } + strikes + .iter() + .enumerate() + .filter(|(_, strike)| strike.is_finite()) + .min_by(|(_, a), (_, b)| { + (*a - reference_price) + .abs() + .partial_cmp(&(*b - reference_price).abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(idx, _)| idx) +} + +/// Label strikes as ITM (1), ATM (0), or OTM (-1). +pub fn label_moneyness(strikes: &[f64], reference_price: f64, kind: OptionKind) -> Vec { + let mut labels = Vec::with_capacity(strikes.len()); + let atm_idx = atm_index(strikes, reference_price); + for (idx, &strike) in strikes.iter().enumerate() { + if Some(idx) == atm_idx { + labels.push(0); + continue; + } + let label = match kind { + OptionKind::Call => { + if strike < reference_price { + 1 + } else { + -1 + } + } + OptionKind::Put => { + if strike > reference_price { + 1 + } else { + -1 + } + } + }; + labels.push(label); + } + labels +} + +/// Select a strike relative to the ATM strike by offset steps. +pub fn select_strike_by_offset( + strikes: &[f64], + reference_price: f64, + offset: isize, +) -> Option { + let idx = atm_index(strikes, reference_price)? as isize + offset; + if idx < 0 || idx >= strikes.len() as isize { + None + } else { + Some(strikes[idx as usize]) + } +} + +/// Select the strike whose delta is closest to the requested target. +pub fn select_strike_by_delta( + strikes: &[f64], + vols: &[f64], + context: ChainGreeksContext, + target_delta: f64, +) -> Option { + if strikes.len() != vols.len() || strikes.is_empty() { + return None; + } + strikes + .iter() + .zip(vols.iter()) + .filter(|(strike, vol)| strike.is_finite() && vol.is_finite()) + .min_by(|(strike_a, vol_a), (strike_b, vol_b)| { + let delta_a = model_greeks(OptionEvaluation { + contract: OptionContract { + model: context.model, + underlying: context.reference_price, + strike: **strike_a, + rate: context.rate, + carry: context.carry, + time_to_expiry: context.time_to_expiry, + kind: context.kind, + }, + volatility: **vol_a, + }) + .delta; + let delta_b = model_greeks(OptionEvaluation { + contract: OptionContract { + model: context.model, + underlying: context.reference_price, + strike: **strike_b, + rate: context.rate, + carry: context.carry, + time_to_expiry: context.time_to_expiry, + kind: context.kind, + }, + volatility: **vol_b, + }) + .delta; + (delta_a - target_delta) + .abs() + .partial_cmp(&(delta_b - target_delta).abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(strike, _)| *strike) +} + +#[cfg(test)] +mod tests { + use super::{atm_index, label_moneyness, select_strike_by_delta, select_strike_by_offset}; + use crate::options::{ChainGreeksContext, OptionKind, PricingModel}; + + #[test] + fn atm_index_finds_nearest() { + let strikes = [90.0, 100.0, 110.0]; + assert_eq!(atm_index(&strikes, 103.0), Some(1)); + } + + #[test] + fn moneyness_labels_calls() { + let strikes = [90.0, 100.0, 110.0]; + assert_eq!( + label_moneyness(&strikes, 100.0, OptionKind::Call), + vec![1, 0, -1] + ); + } + + #[test] + fn offset_selects_expected_strike() { + let strikes = [90.0, 100.0, 110.0]; + assert_eq!(select_strike_by_offset(&strikes, 101.0, 1), Some(110.0)); + } + + #[test] + fn delta_selection_returns_a_strike() { + let strikes = [80.0, 90.0, 100.0, 110.0, 120.0]; + let vols = [0.28, 0.24, 0.20, 0.22, 0.26]; + let strike = select_strike_by_delta( + &strikes, + &vols, + ChainGreeksContext { + model: PricingModel::BlackScholes, + reference_price: 100.0, + rate: 0.01, + carry: 0.0, + time_to_expiry: 0.5, + kind: OptionKind::Call, + }, + 0.25, + ); + assert!(strike.is_some()); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/digital.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/digital.rs new file mode 100644 index 0000000..e1831b5 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/digital.rs @@ -0,0 +1,382 @@ +//! Digital (binary) option pricing. + +use super::normal::cdf; +use super::OptionKind; + +/// Type of digital option payoff. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DigitalKind { + /// Pays 1 unit of cash if option expires in the money. + CashOrNothing, + /// Pays the underlying asset if option expires in the money. + AssetOrNothing, +} + +fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool { + !spot.is_finite() + || !strike.is_finite() + || !time_to_expiry.is_finite() + || !volatility.is_finite() + || spot <= 0.0 + || strike <= 0.0 + || time_to_expiry < 0.0 + || volatility < 0.0 +} + +/// Price a digital (binary) option under BSM. +/// +/// # Parameters +/// - `spot`: current underlying price +/// - `strike`: option strike price +/// - `rate`: risk-free rate (annualized, decimal) +/// - `carry`: continuous dividend yield / carry rate +/// - `time_to_expiry`: time to expiry in years +/// - `volatility`: implied vol (annualized, decimal) +/// - `option_kind`: call or put +/// - `digital_kind`: cash-or-nothing or asset-or-nothing +#[allow(clippy::too_many_arguments)] +pub fn digital_price( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + option_kind: OptionKind, + digital_kind: DigitalKind, +) -> f64 { + if invalid_inputs(spot, strike, time_to_expiry, volatility) + || !rate.is_finite() + || !carry.is_finite() + { + return f64::NAN; + } + + // At expiry: pay intrinsic based on ITM status + if time_to_expiry == 0.0 { + let itm = match option_kind { + OptionKind::Call => spot > strike, + OptionKind::Put => spot < strike, + }; + return if itm { + match digital_kind { + DigitalKind::CashOrNothing => 1.0, + DigitalKind::AssetOrNothing => spot, + } + } else { + 0.0 + }; + } + + let discount = (-rate * time_to_expiry).exp(); + let carry_discount = (-carry * time_to_expiry).exp(); + + // At zero vol: deterministic payoff + if volatility == 0.0 { + let forward = spot * (carry_discount / discount); // S * e^{(r-q)*T} equivalent: S*e^{-q*T}/e^{-r*T} + // forward = S * e^{(r-q)*T}; ITM if forward > K for call + let itm = match option_kind { + OptionKind::Call => spot * carry_discount > strike * discount, + OptionKind::Put => spot * carry_discount < strike * discount, + }; + let _ = forward; // suppress unused warning + return if itm { + match digital_kind { + DigitalKind::CashOrNothing => discount, + DigitalKind::AssetOrNothing => spot * carry_discount, + } + } else { + 0.0 + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let d1 = ((spot / strike).ln() + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + + match digital_kind { + DigitalKind::CashOrNothing => match option_kind { + OptionKind::Call => discount * cdf(d2), + OptionKind::Put => discount * cdf(-d2), + }, + DigitalKind::AssetOrNothing => match option_kind { + OptionKind::Call => spot * carry_discount * cdf(d1), + OptionKind::Put => spot * carry_discount * cdf(-d1), + }, + } +} + +/// Compute numerical delta, gamma, and vega for a digital option. +/// +/// Uses central finite differences: +/// - delta/gamma: bump spot by ε = spot * 1e-3 +/// - vega: bump volatility by 1e-3 +/// +/// Returns `(delta, gamma, vega)`. +#[allow(clippy::too_many_arguments)] +pub fn digital_greeks( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + option_kind: OptionKind, + digital_kind: DigitalKind, +) -> (f64, f64, f64) { + let eps = spot * 1e-3; + if eps <= 0.0 { + return (f64::NAN, f64::NAN, f64::NAN); + } + + let price_mid = digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + option_kind, + digital_kind, + ); + let price_up = digital_price( + spot + eps, + strike, + rate, + carry, + time_to_expiry, + volatility, + option_kind, + digital_kind, + ); + let price_dn = digital_price( + spot - eps, + strike, + rate, + carry, + time_to_expiry, + volatility, + option_kind, + digital_kind, + ); + + let delta = (price_up - price_dn) / (2.0 * eps); + let gamma = (price_up - 2.0 * price_mid + price_dn) / (eps * eps); + + let vol_bump = 1e-3; + let vega = if volatility + vol_bump > 0.0 && volatility - vol_bump > 0.0 { + let price_vup = digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility + vol_bump, + option_kind, + digital_kind, + ); + let price_vdn = digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility - vol_bump, + option_kind, + digital_kind, + ); + (price_vup - price_vdn) / (2.0 * vol_bump) + } else { + // vol too close to zero; one-sided bump + let price_vup = digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility + vol_bump, + option_kind, + digital_kind, + ); + (price_vup - price_mid) / vol_bump + }; + + (delta, gamma, vega) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::OptionKind; + + #[test] + fn cash_or_nothing_call_atm() { + // ATM cash-or-nothing call: price = e^{-rT} * N(d2) + // At S=K=100, r=0.05, q=0, T=1, σ=0.2: + // d1 = (0 + 0.07) / 0.2 = 0.35, d2 = 0.15 → N(0.15) ≈ 0.5596 + // price ≈ e^{-0.05} * 0.5596 ≈ 0.532 + let price = digital_price( + 100.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!( + price > 0.0 && price < 1.0, + "price should be between 0 and 1" + ); + assert!((price - 0.532).abs() < 0.01, "price ≈ 0.532, got {price}"); + } + + #[test] + fn asset_or_nothing_call_at_zero_vol() { + // At zero vol, ITM asset-or-nothing call should equal S * e^{-q*T} + let price = digital_price( + 110.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.0, + OptionKind::Call, + DigitalKind::AssetOrNothing, + ); + assert!((price - 110.0).abs() < 1e-6); + } + + #[test] + fn digital_price_returns_nan_for_invalid() { + let price = digital_price( + -1.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!(price.is_nan()); + } + + #[test] + fn cash_or_nothing_put_call_parity() { + // Cash-or-nothing call + cash-or-nothing put = e^{-rT} + let call = digital_price( + 100.0, + 100.0, + 0.05, + 0.02, + 1.0, + 0.25, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + let put = digital_price( + 100.0, + 100.0, + 0.05, + 0.02, + 1.0, + 0.25, + OptionKind::Put, + DigitalKind::CashOrNothing, + ); + let discount = (-0.05_f64).exp(); + assert!((call + put - discount).abs() < 1e-10); + } + + #[test] + fn asset_or_nothing_put_call_parity() { + // Asset-or-nothing call + asset-or-nothing put = S * e^{-q*T} + let s = 100.0_f64; + let q = 0.02_f64; + let call = digital_price( + s, + 100.0, + 0.05, + q, + 1.0, + 0.25, + OptionKind::Call, + DigitalKind::AssetOrNothing, + ); + let put = digital_price( + s, + 100.0, + 0.05, + q, + 1.0, + 0.25, + OptionKind::Put, + DigitalKind::AssetOrNothing, + ); + let expected = s * (-q).exp(); + assert!((call + put - expected).abs() < 1e-10); + } + + #[test] + fn digital_greeks_are_finite_for_valid_inputs() { + let (delta, gamma, vega) = digital_greeks( + 100.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!(delta.is_finite()); + assert!(gamma.is_finite()); + assert!(vega.is_finite()); + } + + #[test] + fn digital_at_expiry_itm_returns_intrinsic() { + let price = digital_price( + 110.0, + 100.0, + 0.05, + 0.0, + 0.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!((price - 1.0).abs() < 1e-10); + let price2 = digital_price( + 110.0, + 100.0, + 0.05, + 0.0, + 0.0, + 0.2, + OptionKind::Call, + DigitalKind::AssetOrNothing, + ); + assert!((price2 - 110.0).abs() < 1e-10); + } + + #[test] + fn digital_at_expiry_otm_returns_zero() { + let price = digital_price( + 90.0, + 100.0, + 0.05, + 0.0, + 0.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!((price - 0.0).abs() < 1e-10); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/greeks.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/greeks.rs new file mode 100644 index 0000000..cae6761 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/greeks.rs @@ -0,0 +1,327 @@ +//! Option Greeks. + +use super::normal::{cdf, pdf}; +use super::pricing::{black_76_price, black_scholes_price}; +use super::{ExtendedGreeks, Greeks, OptionEvaluation, OptionKind, PricingModel}; + +fn bs_inputs_valid( + underlying: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, +) -> bool { + underlying.is_finite() + && strike.is_finite() + && rate.is_finite() + && carry.is_finite() + && time_to_expiry.is_finite() + && volatility.is_finite() + && underlying > 0.0 + && strike > 0.0 + && time_to_expiry > 0.0 + && volatility > 0.0 +} + +fn numerical_theta(time_to_expiry: f64, price_fn: F) -> f64 +where + F: Fn(f64) -> f64, +{ + if time_to_expiry <= 0.0 { + return 0.0; + } + let h = time_to_expiry.clamp(1e-6, 1.0 / 365.0); + let t_minus = (time_to_expiry - h).max(1e-8); + let t_plus = time_to_expiry + h; + let price_minus = price_fn(t_minus); + let price_plus = price_fn(t_plus); + (price_minus - price_plus) / (t_plus - t_minus) +} + +/// Black-Scholes-Merton Greeks. +pub fn black_scholes_greeks( + spot: f64, + strike: f64, + rate: f64, + dividend_yield: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> Greeks { + if !bs_inputs_valid( + spot, + strike, + rate, + dividend_yield, + time_to_expiry, + volatility, + ) { + return Greeks { + delta: f64::NAN, + gamma: f64::NAN, + vega: f64::NAN, + theta: f64::NAN, + rho: f64::NAN, + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let discount = (-rate * time_to_expiry).exp(); + let carry_discount = (-dividend_yield * time_to_expiry).exp(); + let d1 = ((spot / strike).ln() + + (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + let pdf_d1 = pdf(d1); + + let delta = match kind { + OptionKind::Call => carry_discount * cdf(d1), + OptionKind::Put => carry_discount * (cdf(d1) - 1.0), + }; + let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t); + let vega = spot * carry_discount * pdf_d1 * sqrt_t; + let theta = match kind { + OptionKind::Call => { + -(spot * carry_discount * pdf_d1 * volatility) / (2.0 * sqrt_t) + - rate * strike * discount * cdf(d2) + + dividend_yield * spot * carry_discount * cdf(d1) + } + OptionKind::Put => { + -(spot * carry_discount * pdf_d1 * volatility) / (2.0 * sqrt_t) + + rate * strike * discount * cdf(-d2) + - dividend_yield * spot * carry_discount * cdf(-d1) + } + }; + let rho = match kind { + OptionKind::Call => strike * time_to_expiry * discount * cdf(d2), + OptionKind::Put => -strike * time_to_expiry * discount * cdf(-d2), + }; + + Greeks { + delta, + gamma, + vega, + theta, + rho, + } +} + +/// Black-76 Greeks with respect to the forward. +pub fn black_76_greeks( + forward: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> Greeks { + if !bs_inputs_valid(forward, strike, rate, 0.0, time_to_expiry, volatility) { + return Greeks { + delta: f64::NAN, + gamma: f64::NAN, + vega: f64::NAN, + theta: f64::NAN, + rho: f64::NAN, + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let discount = (-rate * time_to_expiry).exp(); + let d1 = + ((forward / strike).ln() + 0.5 * volatility * volatility * time_to_expiry) / sigma_sqrt_t; + let pdf_d1 = pdf(d1); + + let delta = match kind { + OptionKind::Call => discount * cdf(d1), + OptionKind::Put => -discount * cdf(-d1), + }; + let gamma = discount * pdf_d1 / (forward * sigma_sqrt_t); + let vega = discount * forward * pdf_d1 * sqrt_t; + let theta = numerical_theta(time_to_expiry, |t| { + black_76_price(forward, strike, rate, t, volatility, kind) + }); + let rho = + -time_to_expiry * black_76_price(forward, strike, rate, time_to_expiry, volatility, kind); + + Greeks { + delta, + gamma, + vega, + theta, + rho, + } +} + +/// Model-dispatched Greeks. +pub fn model_greeks(input: OptionEvaluation) -> Greeks { + let contract = input.contract; + match contract.model { + PricingModel::BlackScholes => black_scholes_greeks( + contract.underlying, + contract.strike, + contract.rate, + contract.carry, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + PricingModel::Black76 => black_76_greeks( + contract.underlying, + contract.strike, + contract.rate, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + } +} + +/// Price derivative with respect to calendar time using the selected model. +pub fn model_theta(input: OptionEvaluation) -> f64 { + let contract = input.contract; + numerical_theta(contract.time_to_expiry, |t| match contract.model { + PricingModel::BlackScholes => black_scholes_price( + contract.underlying, + contract.strike, + contract.rate, + contract.carry, + t, + input.volatility, + contract.kind, + ), + PricingModel::Black76 => black_76_price( + contract.underlying, + contract.strike, + contract.rate, + t, + input.volatility, + contract.kind, + ), + }) +} + +/// Extended Greeks under Black-Scholes-Merton (closed-form). +/// +/// All inputs must be positive finite; returns NaN fields for invalid inputs. +pub fn black_scholes_extended_greeks( + spot: f64, + strike: f64, + rate: f64, + dividend_yield: f64, + time_to_expiry: f64, + volatility: f64, + _kind: OptionKind, +) -> ExtendedGreeks { + if !bs_inputs_valid( + spot, + strike, + rate, + dividend_yield, + time_to_expiry, + volatility, + ) { + return ExtendedGreeks { + vanna: f64::NAN, + volga: f64::NAN, + charm: f64::NAN, + speed: f64::NAN, + color: f64::NAN, + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let carry_discount = (-dividend_yield * time_to_expiry).exp(); + let d1 = ((spot / strike).ln() + + (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + let pdf_d1 = pdf(d1); + + let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t); + + let vanna = -carry_discount * pdf_d1 * d2 / volatility; + let volga = spot * carry_discount * pdf_d1 * sqrt_t * d1 * d2 / volatility; + let charm = -carry_discount + * pdf_d1 + * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t) + / (2.0 * time_to_expiry * sigma_sqrt_t); + let speed = -gamma / spot * (d1 / sigma_sqrt_t + 1.0); + let color = -carry_discount * pdf_d1 / (2.0 * spot * time_to_expiry * sigma_sqrt_t) + * (2.0 * (rate - dividend_yield) * time_to_expiry + 1.0 + - d1 * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t) + / sigma_sqrt_t); + + ExtendedGreeks { + vanna, + volga, + charm, + speed, + color, + } +} + +/// Model-dispatched extended Greeks. +/// Only BSM is supported with closed-form; Black-76 is not yet supported (returns NaN). +pub fn model_extended_greeks(input: OptionEvaluation) -> ExtendedGreeks { + let contract = input.contract; + match contract.model { + PricingModel::BlackScholes => black_scholes_extended_greeks( + contract.underlying, + contract.strike, + contract.rate, + contract.carry, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + PricingModel::Black76 => ExtendedGreeks { + vanna: f64::NAN, + volga: f64::NAN, + charm: f64::NAN, + speed: f64::NAN, + color: f64::NAN, + }, + } +} + +#[cfg(test)] +mod tests { + use super::{black_76_greeks, black_scholes_extended_greeks, black_scholes_greeks}; + use crate::options::OptionKind; + + #[test] + fn bsm_greeks_are_finite() { + let g = black_scholes_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + assert!(g.delta.is_finite()); + assert!(g.gamma.is_finite()); + assert!(g.vega.is_finite()); + assert!(g.theta.is_finite()); + assert!(g.rho.is_finite()); + } + + #[test] + fn black_76_greeks_are_finite() { + let g = black_76_greeks(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Put); + assert!(g.delta.is_finite()); + assert!(g.gamma.is_finite()); + assert!(g.vega.is_finite()); + assert!(g.theta.is_finite()); + assert!(g.rho.is_finite()); + } + + #[test] + fn extended_greeks_finite_for_valid_inputs() { + let eg = black_scholes_extended_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + assert!(eg.vanna.is_finite()); + assert!(eg.volga.is_finite()); + assert!(eg.charm.is_finite()); + assert!(eg.speed.is_finite()); + assert!(eg.color.is_finite()); + // Volga must be positive (convex in vol) + assert!(eg.volga >= 0.0); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/iv.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/iv.rs new file mode 100644 index 0000000..2302886 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/iv.rs @@ -0,0 +1,241 @@ +//! Implied volatility inversion and IV-series helpers. + +use super::greeks::model_greeks; +use super::pricing::{model_price, price_lower_bound, price_upper_bound}; +use super::{IvSolverConfig, OptionContract, OptionEvaluation}; + +/// Solve implied volatility with guarded Newton iterations and bisection fallback. +pub fn implied_volatility( + contract: OptionContract, + target_price: f64, + config: IvSolverConfig, +) -> f64 { + if !target_price.is_finite() + || !contract.underlying.is_finite() + || !contract.strike.is_finite() + || !contract.rate.is_finite() + || !contract.carry.is_finite() + || !contract.time_to_expiry.is_finite() + || target_price < 0.0 + || contract.underlying <= 0.0 + || contract.strike <= 0.0 + || contract.time_to_expiry < 0.0 + { + return f64::NAN; + } + if contract.time_to_expiry == 0.0 { + return 0.0; + } + + let lower = price_lower_bound(contract); + let upper = price_upper_bound(contract); + if target_price < lower - config.tolerance || target_price > upper + config.tolerance { + return f64::NAN; + } + if (target_price - lower).abs() <= config.tolerance { + return 0.0; + } + + let mut low_vol = 1e-9; + let mut high_vol = config.initial_guess.max(0.25).max(low_vol * 10.0); + let mut high_price = model_price(OptionEvaluation { + contract, + volatility: high_vol, + }); + while high_price < target_price && high_vol < 10.0 { + high_vol *= 2.0; + high_price = model_price(OptionEvaluation { + contract, + volatility: high_vol, + }); + } + if high_price < target_price { + return f64::NAN; + } + + let mut vol = config.initial_guess.clamp(low_vol, high_vol).max(1e-4); + for _ in 0..config.max_iterations.max(1) { + let price = model_price(OptionEvaluation { + contract, + volatility: vol, + }); + let diff = price - target_price; + if diff.abs() <= config.tolerance { + return vol; + } + + if diff > 0.0 { + high_vol = high_vol.min(vol); + } else { + low_vol = low_vol.max(vol); + } + + let vega = model_greeks(OptionEvaluation { + contract, + volatility: vol, + }) + .vega; + + let next = if vega.is_finite() && vega.abs() > 1e-10 { + let candidate = vol - diff / vega; + if candidate > low_vol && candidate < high_vol { + candidate + } else { + 0.5 * (low_vol + high_vol) + } + } else { + 0.5 * (low_vol + high_vol) + }; + vol = next; + } + + let final_price = model_price(OptionEvaluation { + contract, + volatility: vol, + }); + if (final_price - target_price).abs() <= config.tolerance * 10.0 { + vol + } else { + f64::NAN + } +} + +fn validate_window(window: usize) -> bool { + window >= 1 +} + +/// Rolling IV rank. +pub fn iv_rank(iv_series: &[f64], window: usize) -> Vec { + let n = iv_series.len(); + let mut out = vec![f64::NAN; n]; + if !validate_window(window) || n < window { + return out; + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let mut min_v = f64::INFINITY; + let mut max_v = f64::NEG_INFINITY; + for &v in &iv_series[start..=end] { + if v.is_finite() { + min_v = min_v.min(v); + max_v = max_v.max(v); + } + } + let current = iv_series[end]; + if !current.is_finite() || !min_v.is_finite() || !max_v.is_finite() { + out[end] = f64::NAN; + continue; + } + let spread = max_v - min_v; + out[end] = if spread == 0.0 { + 0.0 + } else { + (current - min_v) / spread + }; + } + out +} + +/// Rolling IV percentile. +pub fn iv_percentile(iv_series: &[f64], window: usize) -> Vec { + let n = iv_series.len(); + let mut out = vec![f64::NAN; n]; + if !validate_window(window) || n < window { + return out; + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let current = iv_series[end]; + let count = iv_series[start..=end] + .iter() + .filter(|&&v| v <= current) + .count(); + out[end] = count as f64 / window as f64; + } + out +} + +/// Rolling IV z-score. +pub fn iv_zscore(iv_series: &[f64], window: usize) -> Vec { + let n = iv_series.len(); + let mut out = vec![f64::NAN; n]; + if !validate_window(window) || n < window { + return out; + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let mut count = 0usize; + let mut sum = 0.0; + for &v in &iv_series[start..=end] { + if v.is_finite() { + count += 1; + sum += v; + } + } + if count == 0 { + out[end] = f64::NAN; + continue; + } + let mean = sum / count as f64; + let mut var = 0.0; + for &v in &iv_series[start..=end] { + if v.is_finite() { + let d = v - mean; + var += d * d; + } + } + let std = (var / count as f64).sqrt(); + let current = iv_series[end]; + out[end] = if !current.is_finite() || std == 0.0 { + f64::NAN + } else { + (current - mean) / std + }; + } + out +} + +#[cfg(test)] +mod tests { + use super::{implied_volatility, iv_percentile, iv_rank, iv_zscore}; + use crate::options::pricing::black_scholes_price; + use crate::options::{IvSolverConfig, OptionContract, OptionKind, PricingModel}; + + #[test] + fn solver_recovers_input_vol() { + let price = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + let iv = implied_volatility( + OptionContract { + model: PricingModel::BlackScholes, + underlying: 100.0, + strike: 100.0, + rate: 0.05, + carry: 0.0, + time_to_expiry: 1.0, + kind: OptionKind::Call, + }, + price, + IvSolverConfig { + initial_guess: 0.3, + tolerance: 1e-8, + max_iterations: 100, + }, + ); + assert!((iv - 0.2).abs() < 1e-6); + } + + #[test] + fn iv_helpers_match_expected_values() { + let iv = [10.0, 20.0, 30.0, 15.0, 22.0]; + let rank = iv_rank(&iv, 3); + let pct = iv_percentile(&iv, 3); + let z = iv_zscore(&iv, 3); + assert!(rank[0].is_nan() && rank[1].is_nan()); + assert!((rank[2] - 1.0).abs() < 1e-12); + assert!((pct[3] - (1.0 / 3.0)).abs() < 1e-12); + assert!((z[2] - 1.224_744_871).abs() < 1e-6); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/mod.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/mod.rs new file mode 100644 index 0000000..5d806fa --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/mod.rs @@ -0,0 +1,102 @@ +//! Options analytics core. +//! +//! This module contains pricing, Greeks, implied volatility inversion, +//! IV-series helpers, and smile/chain utilities. The public API is scalar-first +//! and is used by the PyO3 bridge to build vectorized batch functions. + +pub mod american; +pub mod chain; +pub mod digital; +pub mod greeks; +pub mod iv; +pub mod normal; +pub mod payoff; +pub mod pricing; +pub mod realized_vol; +pub mod surface; + +/// Option side. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OptionKind { + /// Call option. + Call, + /// Put option. + Put, +} + +impl OptionKind { + /// Returns +1 for calls and -1 for puts. + pub fn sign(self) -> f64 { + match self { + Self::Call => 1.0, + Self::Put => -1.0, + } + } +} + +/// Supported pricing models. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PricingModel { + /// Black-Scholes-Merton with continuous carry/dividend yield. + BlackScholes, + /// Black-76 using the forward price as the underlying input. + Black76, +} + +/// Primary first-order Greeks returned by the pricing engine. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Greeks { + pub delta: f64, + pub gamma: f64, + pub vega: f64, + pub theta: f64, + pub rho: f64, +} + +/// Second-order and cross Greeks. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ExtendedGreeks { + pub vanna: f64, // ∂Δ/∂σ + pub volga: f64, // ∂²V/∂σ² (vomma) + pub charm: f64, // ∂Δ/∂t + pub speed: f64, // ∂Γ/∂S + pub color: f64, // ∂Γ/∂t +} + +/// Shared contract fields for model-based option analytics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OptionContract { + pub model: PricingModel, + pub underlying: f64, + pub strike: f64, + pub rate: f64, + pub carry: f64, + pub time_to_expiry: f64, + pub kind: OptionKind, +} + +/// Contract plus volatility for pricing and Greeks. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OptionEvaluation { + pub contract: OptionContract, + pub volatility: f64, +} + +/// Solver configuration for implied volatility inversion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct IvSolverConfig { + pub initial_guess: f64, + pub tolerance: f64, + pub max_iterations: usize, +} + +/// Shared context for strike selection and smile analytics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ChainGreeksContext { + pub model: PricingModel, + pub reference_price: f64, + pub rate: f64, + pub carry: f64, + pub time_to_expiry: f64, + pub kind: OptionKind, +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/normal.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/normal.rs new file mode 100644 index 0000000..bb66380 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/normal.rs @@ -0,0 +1,44 @@ +//! Normal distribution helpers. + +const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7; + +/// Standard normal probability density function. +pub fn pdf(x: f64) -> f64 { + INV_SQRT_2PI * (-0.5 * x * x).exp() +} + +/// Standard normal cumulative distribution function. +/// +/// Uses a common Abramowitz-Stegun style approximation that is fast and +/// sufficiently accurate for option pricing work. +pub fn cdf(x: f64) -> f64 { + let ax = x.abs(); + let t = 1.0 / (1.0 + 0.231_641_9 * ax); + let poly = (((((1.330_274_429 * t - 1.821_255_978) * t) + 1.781_477_937) * t - 0.356_563_782) + * t + + 0.319_381_530) + * t; + let approx = 1.0 - pdf(ax) * poly; + if x >= 0.0 { + approx + } else { + 1.0 - approx + } +} + +#[cfg(test)] +mod tests { + use super::{cdf, pdf}; + + #[test] + fn cdf_is_reasonable() { + assert!((cdf(0.0) - 0.5).abs() < 1e-7); + assert!((cdf(1.0) - 0.841_344_746).abs() < 5e-5); + assert!((cdf(-1.0) - 0.158_655_254).abs() < 5e-5); + } + + #[test] + fn pdf_is_reasonable() { + assert!((pdf(0.0) - 0.398_942_280_4).abs() < 1e-10); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/payoff.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/payoff.rs new file mode 100644 index 0000000..a1fd4f3 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/payoff.rs @@ -0,0 +1,392 @@ +//! Pure-Rust (no PyO3, no numpy) strategy payoff and value functions. +//! +//! NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;` +//! for this module to be reachable from the rest of the crate and from the PyO3 bridge. + +use super::pricing::black_scholes_price; +use super::OptionKind; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Instrument codes: 0=option, 1=future, 2=stock. +const INSTRUMENT_OPTION: i64 = 0; +const INSTRUMENT_FUTURE: i64 = 1; +const INSTRUMENT_STOCK: i64 = 2; + +/// Side sign from encoded value: 1=long (+1.0), -1=short (-1.0). +#[inline] +fn side_sign(v: i64) -> f64 { + if v == 1 { + 1.0 + } else if v == -1 { + -1.0 + } else { + f64::NAN + } +} + +/// Option kind from encoded value: 1=call, -1=put. +#[inline] +fn option_kind(v: i64) -> Option { + match v { + 1 => Some(OptionKind::Call), + -1 => Some(OptionKind::Put), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// strategy_payoff_dense +// --------------------------------------------------------------------------- + +/// Aggregate strategy payoff over a spot grid. +/// +/// Parameters (all slices of length n_legs): +/// - `instruments`: 0=option, 1=future, 2=stock +/// - `sides`: 1=long, -1=short +/// - `option_types`: 1=call, -1=put (ignored for futures/stocks) +/// - `strikes`: strike for options +/// - `premiums`: premium for options +/// - `entry_prices`: entry price for futures/stocks +/// - `quantities`, `multipliers`: applied to all instruments +/// +/// Returns a Vec of length spot_grid.len() with aggregate P&L per spot point. +#[allow(clippy::too_many_arguments)] +pub fn strategy_payoff_dense( + spot_grid: &[f64], + instruments: &[i64], + sides: &[i64], + option_types: &[i64], + strikes: &[f64], + premiums: &[f64], + entry_prices: &[f64], + quantities: &[f64], + multipliers: &[f64], +) -> Vec { + let n_legs = instruments.len(); + // Validate that all leg slices are the same length; return zeros if not. + if sides.len() != n_legs + || option_types.len() != n_legs + || strikes.len() != n_legs + || premiums.len() != n_legs + || entry_prices.len() != n_legs + || quantities.len() != n_legs + || multipliers.len() != n_legs + { + return vec![0.0; spot_grid.len()]; + } + + let mut total = vec![0.0_f64; spot_grid.len()]; + + for leg_idx in 0..n_legs { + let inst = instruments[leg_idx]; + let sign = side_sign(sides[leg_idx]); + if sign.is_nan() { + // Invalid side — skip leg (treat as zero contribution). + continue; + } + let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx]; + + match inst { + INSTRUMENT_OPTION => { + let kind = match option_kind(option_types[leg_idx]) { + Some(k) => k, + None => continue, // Invalid option type — skip. + }; + let k = strikes[leg_idx]; + let p = premiums[leg_idx]; + for (i, &s) in spot_grid.iter().enumerate() { + let intrinsic = match kind { + OptionKind::Call => (s - k).max(0.0), + OptionKind::Put => (k - s).max(0.0), + }; + total[i] += leg_scale * (intrinsic - p); + } + } + INSTRUMENT_FUTURE | INSTRUMENT_STOCK => { + let e = entry_prices[leg_idx]; + for (i, &s) in spot_grid.iter().enumerate() { + total[i] += leg_scale * (s - e); + } + } + _ => { + // Unknown instrument code — skip leg (NaN would propagate; zeros are safer). + } + } + } + + total +} + +// --------------------------------------------------------------------------- +// strategy_value_dense / strategy_value_grid +// --------------------------------------------------------------------------- + +/// Current BSM value of a strategy at a single spot (pre-expiry). +/// +/// Unlike `strategy_payoff_dense`, this uses BSM pricing for option legs rather +/// than intrinsic value. +/// +/// Parameters: same as `strategy_payoff_dense` plus per-leg BSM inputs: +/// - `time_to_expiries`: TTE for each option leg (ignored for futures/stocks) +/// - `volatilities`: vol for each option leg (ignored for futures/stocks) +/// - `rates`: risk-free rate for each leg +/// - `carries`: carry/dividend yield for each option leg +/// +/// Returns a scalar f64 (strategy P&L at the given spot). +#[allow(clippy::too_many_arguments)] +pub fn strategy_value_dense( + spot: f64, + instruments: &[i64], + sides: &[i64], + option_types: &[i64], + strikes: &[f64], + premiums: &[f64], + entry_prices: &[f64], + quantities: &[f64], + multipliers: &[f64], + time_to_expiries: &[f64], + volatilities: &[f64], + rates: &[f64], + carries: &[f64], +) -> f64 { + let n_legs = instruments.len(); + // Validate that all leg slices are the same length; return NaN if not. + if sides.len() != n_legs + || option_types.len() != n_legs + || strikes.len() != n_legs + || premiums.len() != n_legs + || entry_prices.len() != n_legs + || quantities.len() != n_legs + || multipliers.len() != n_legs + || time_to_expiries.len() != n_legs + || volatilities.len() != n_legs + || rates.len() != n_legs + || carries.len() != n_legs + { + return f64::NAN; + } + + let mut total = 0.0_f64; + + for leg_idx in 0..n_legs { + let inst = instruments[leg_idx]; + let sign = side_sign(sides[leg_idx]); + if sign.is_nan() { + continue; + } + let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx]; + + match inst { + INSTRUMENT_OPTION => { + let kind = match option_kind(option_types[leg_idx]) { + Some(k) => k, + None => continue, + }; + let bsm = black_scholes_price( + spot, + strikes[leg_idx], + rates[leg_idx], + carries[leg_idx], + time_to_expiries[leg_idx], + volatilities[leg_idx], + kind, + ); + total += leg_scale * (bsm - premiums[leg_idx]); + } + INSTRUMENT_FUTURE | INSTRUMENT_STOCK => { + total += leg_scale * (spot - entry_prices[leg_idx]); + } + _ => {} + } + } + + total +} + +// --------------------------------------------------------------------------- +// aggregate_greeks_dense +// --------------------------------------------------------------------------- + +/// Aggregate BSM Greeks for a multi-leg strategy at a single spot. +/// +/// Parameters (all slices of length n_legs): +/// - `instruments`: 0=option, 1=future, 2=stock +/// - `sides`: 1=long, -1=short +/// - `option_types`: 1=call, -1=put (ignored for futures/stocks) +/// - `strikes`: strike price for option legs +/// - `volatilities`: implied vol for option legs +/// - `time_to_expiries`: TTE in years for option legs +/// - `rates`: risk-free rate for each leg +/// - `carries`: carry/dividend yield for option legs +/// - `quantities`, `multipliers`: applied to all instruments +/// +/// Returns `(delta, gamma, vega, theta, rho)` aggregate across all legs. +/// Future/stock legs contribute `leg_scale` to delta only (all other Greeks = 0). +#[allow(clippy::too_many_arguments)] +pub fn aggregate_greeks_dense( + spot: f64, + instruments: &[i64], + sides: &[i64], + option_types: &[i64], + strikes: &[f64], + volatilities: &[f64], + time_to_expiries: &[f64], + rates: &[f64], + carries: &[f64], + quantities: &[f64], + multipliers: &[f64], +) -> (f64, f64, f64, f64, f64) { + use super::greeks::model_greeks; + use super::{OptionContract, OptionEvaluation, PricingModel}; + + let n_legs = instruments.len(); + if sides.len() != n_legs + || option_types.len() != n_legs + || strikes.len() != n_legs + || volatilities.len() != n_legs + || time_to_expiries.len() != n_legs + || rates.len() != n_legs + || carries.len() != n_legs + || quantities.len() != n_legs + || multipliers.len() != n_legs + { + return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN); + } + + let mut delta = 0.0_f64; + let mut gamma = 0.0_f64; + let mut vega = 0.0_f64; + let mut theta = 0.0_f64; + let mut rho = 0.0_f64; + + for i in 0..n_legs { + let sign = side_sign(sides[i]); + if sign.is_nan() { + continue; + } + let leg_scale = sign * quantities[i] * multipliers[i]; + + match instruments[i] { + INSTRUMENT_FUTURE | INSTRUMENT_STOCK => { + delta += leg_scale; + } + INSTRUMENT_OPTION => { + let kind = match option_kind(option_types[i]) { + Some(k) => k, + None => continue, + }; + let greeks = model_greeks(OptionEvaluation { + contract: OptionContract { + model: PricingModel::BlackScholes, + underlying: spot, + strike: strikes[i], + rate: rates[i], + carry: carries[i], + time_to_expiry: time_to_expiries[i], + kind, + }, + volatility: volatilities[i], + }); + delta += leg_scale * greeks.delta; + gamma += leg_scale * greeks.gamma; + vega += leg_scale * greeks.vega; + theta += leg_scale * greeks.theta; + rho += leg_scale * greeks.rho; + } + _ => {} + } + } + + (delta, gamma, vega, theta, rho) +} + +/// Evaluate `strategy_value_dense` for each point in `spot_grid`. +/// +/// Returns a `Vec` of length `spot_grid.len()`. +#[allow(clippy::too_many_arguments)] +pub fn strategy_value_grid( + spot_grid: &[f64], + instruments: &[i64], + sides: &[i64], + option_types: &[i64], + strikes: &[f64], + premiums: &[f64], + entry_prices: &[f64], + quantities: &[f64], + multipliers: &[f64], + time_to_expiries: &[f64], + volatilities: &[f64], + rates: &[f64], + carries: &[f64], +) -> Vec { + spot_grid + .iter() + .map(|&s| { + strategy_value_dense( + s, + instruments, + sides, + option_types, + strikes, + premiums, + entry_prices, + quantities, + multipliers, + time_to_expiries, + volatilities, + rates, + carries, + ) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payoff_single_call() { + let grid = vec![90.0, 100.0, 110.0, 120.0]; + let out = strategy_payoff_dense( + &grid, + &[0], + &[1], + &[1], + &[100.0], + &[5.0], + &[0.0], + &[1.0], + &[1.0], + ); + assert!(out[0] < 0.0); // below strike, loss = premium + assert!((out[0] - (-5.0)).abs() < 1e-10); + assert!((out[2] - 5.0).abs() < 1e-10); // at 110, intrinsic=10, net=10-5=5 + } + + #[test] + fn stock_leg_linear() { + let grid = vec![90.0, 100.0, 110.0]; + let out = strategy_payoff_dense( + &grid, + &[2], + &[1], + &[0], + &[0.0], + &[0.0], + &[100.0], + &[1.0], + &[1.0], + ); + assert!((out[0] - (-10.0)).abs() < 1e-10); + assert!((out[1] - 0.0).abs() < 1e-10); + assert!((out[2] - 10.0).abs() < 1e-10); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/pricing.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/pricing.rs new file mode 100644 index 0000000..44983ac --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/pricing.rs @@ -0,0 +1,218 @@ +//! Option pricing models. + +use super::normal::cdf; +use super::{OptionContract, OptionEvaluation, OptionKind, PricingModel}; + +fn invalid_inputs(underlying: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool { + !underlying.is_finite() + || !strike.is_finite() + || !time_to_expiry.is_finite() + || !volatility.is_finite() + || underlying <= 0.0 + || strike <= 0.0 + || time_to_expiry < 0.0 + || volatility < 0.0 +} + +/// Black-Scholes-Merton price with continuous carry/dividend yield. +pub fn black_scholes_price( + spot: f64, + strike: f64, + rate: f64, + dividend_yield: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> f64 { + if invalid_inputs(spot, strike, time_to_expiry, volatility) || !rate.is_finite() { + return f64::NAN; + } + if time_to_expiry == 0.0 { + return match kind { + OptionKind::Call => (spot - strike).max(0.0), + OptionKind::Put => (strike - spot).max(0.0), + }; + } + + let discount = (-rate * time_to_expiry).exp(); + let carry_discount = (-dividend_yield * time_to_expiry).exp(); + if volatility == 0.0 { + return match kind { + OptionKind::Call => (spot * carry_discount - strike * discount).max(0.0), + OptionKind::Put => (strike * discount - spot * carry_discount).max(0.0), + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let d1 = ((spot / strike).ln() + + (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + + match kind { + OptionKind::Call => spot * carry_discount * cdf(d1) - strike * discount * cdf(d2), + OptionKind::Put => strike * discount * cdf(-d2) - spot * carry_discount * cdf(-d1), + } +} + +/// Black-76 price using the forward price as the underlying input. +pub fn black_76_price( + forward: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> f64 { + if invalid_inputs(forward, strike, time_to_expiry, volatility) || !rate.is_finite() { + return f64::NAN; + } + let discount = (-rate * time_to_expiry).exp(); + if time_to_expiry == 0.0 { + return discount + * match kind { + OptionKind::Call => (forward - strike).max(0.0), + OptionKind::Put => (strike - forward).max(0.0), + }; + } + if volatility == 0.0 { + return discount + * match kind { + OptionKind::Call => (forward - strike).max(0.0), + OptionKind::Put => (strike - forward).max(0.0), + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let d1 = + ((forward / strike).ln() + 0.5 * volatility * volatility * time_to_expiry) / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + + let signed = kind.sign(); + discount * signed * (forward * cdf(signed * d1) - strike * cdf(signed * d2)) +} + +/// Model-dispatched option price. +pub fn model_price(input: OptionEvaluation) -> f64 { + let contract = input.contract; + match contract.model { + PricingModel::BlackScholes => black_scholes_price( + contract.underlying, + contract.strike, + contract.rate, + contract.carry, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + PricingModel::Black76 => black_76_price( + contract.underlying, + contract.strike, + contract.rate, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + } +} + +/// Put-call parity deviation: `C - P - (S·e^{-q·T} - K·e^{-r·T})`. +/// +/// Returns 0.0 when no arbitrage exists. A non-zero value indicates the +/// magnitude of mispricing or data error. +pub fn put_call_parity_deviation( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, +) -> f64 { + if !call_price.is_finite() + || !put_price.is_finite() + || !spot.is_finite() + || !strike.is_finite() + || !rate.is_finite() + || !carry.is_finite() + || !time_to_expiry.is_finite() + || spot <= 0.0 + || strike <= 0.0 + || time_to_expiry < 0.0 + { + return f64::NAN; + } + let pv_forward = spot * (-carry * time_to_expiry).exp(); + let pv_strike = strike * (-rate * time_to_expiry).exp(); + call_price - put_price - (pv_forward - pv_strike) +} + +/// Lower no-arbitrage bound for the option price. +pub fn price_lower_bound(contract: OptionContract) -> f64 { + match contract.model { + PricingModel::BlackScholes => { + let discount = (-contract.rate * contract.time_to_expiry).exp(); + let carry_discount = (-contract.carry * contract.time_to_expiry).exp(); + match contract.kind { + OptionKind::Call => { + (contract.underlying * carry_discount - contract.strike * discount).max(0.0) + } + OptionKind::Put => { + (contract.strike * discount - contract.underlying * carry_discount).max(0.0) + } + } + } + PricingModel::Black76 => { + let discount = (-contract.rate * contract.time_to_expiry).exp(); + discount + * match contract.kind { + OptionKind::Call => (contract.underlying - contract.strike).max(0.0), + OptionKind::Put => (contract.strike - contract.underlying).max(0.0), + } + } + } +} + +/// Upper no-arbitrage bound for the option price. +pub fn price_upper_bound(contract: OptionContract) -> f64 { + match contract.model { + PricingModel::BlackScholes => match contract.kind { + OptionKind::Call => { + contract.underlying * (-contract.carry * contract.time_to_expiry).exp() + } + OptionKind::Put => contract.strike * (-contract.rate * contract.time_to_expiry).exp(), + }, + PricingModel::Black76 => { + let discount = (-contract.rate * contract.time_to_expiry).exp(); + discount + * match contract.kind { + OptionKind::Call => contract.underlying, + OptionKind::Put => contract.strike, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{black_76_price, black_scholes_price}; + use crate::options::OptionKind; + + #[test] + fn black_scholes_prices_are_reasonable() { + let call = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + let put = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put); + assert!((call - 10.4506).abs() < 1e-3); + assert!((put - 5.5735).abs() < 1e-3); + } + + #[test] + fn black_76_prices_are_reasonable() { + let call = black_76_price(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Call); + let put = black_76_price(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Put); + assert!((call - 7.730_148).abs() < 1e-3); + assert!((put - 7.730_148).abs() < 1e-3); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/realized_vol.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/realized_vol.rs new file mode 100644 index 0000000..7270f96 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/realized_vol.rs @@ -0,0 +1,445 @@ +//! Historical (realized) volatility estimators and volatility cone. + +/// Rolling close-to-close realized volatility. +/// +/// Returns a `Vec` of the same length as `close`. The first `window` values +/// are NaN (we need `window` log-returns, which require `window+1` prices, so the +/// first valid output sits at index `window`). +/// +/// Annualization: `sqrt(sum(r²) / window * trading_days)`. +pub fn close_to_close_vol(close: &[f64], window: usize, trading_days: f64) -> Vec { + let n = close.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n <= window { + return out; + } + + // Precompute log-returns; returns[i] = ln(close[i+1] / close[i]) + let mut returns = vec![f64::NAN; n - 1]; + for i in 0..(n - 1) { + if close[i] > 0.0 && close[i + 1] > 0.0 { + returns[i] = (close[i + 1] / close[i]).ln(); + } + } + + // Rolling sum of squared returns over `window` bars. + // The output at position `end` (in the original close array) uses + // returns[end-window .. end-1], i.e. `window` returns. + for end in window..n { + let slice = &returns[(end - window)..end]; + let sum_sq: f64 = slice.iter().map(|&r| r * r).sum(); + let var = sum_sq / window as f64 * trading_days; + out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN }; + } + out +} + +/// Rolling Parkinson high-low realized volatility estimator. +/// +/// Returns a `Vec` of the same length as `high`. The first `window-1` values +/// are NaN. +#[allow(clippy::needless_range_loop)] +pub fn parkinson_vol(high: &[f64], low: &[f64], window: usize, trading_days: f64) -> Vec { + let n = high.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n < window || low.len() != n { + return out; + } + + let factor = 1.0 / (4.0 * 2_f64.ln()); + + for end in (window - 1)..n { + let start = end + 1 - window; + let mut sum_sq = 0.0; + let mut valid = true; + for i in start..=end { + if high[i] <= 0.0 || low[i] <= 0.0 || !high[i].is_finite() || !low[i].is_finite() { + valid = false; + break; + } + let u = (high[i] / low[i]).ln(); + sum_sq += u * u; + } + if valid { + let var = factor * sum_sq / window as f64 * trading_days; + out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN }; + } + } + out +} + +/// Rolling Garman-Klass OHLC realized volatility estimator. +/// +/// Returns a `Vec` of the same length as the inputs. The first `window-1` +/// values are NaN. All four slices must have the same length. +pub fn garman_klass_vol( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + window: usize, + trading_days: f64, +) -> Vec { + let n = open.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n { + return out; + } + + let ln2 = 2_f64.ln(); + + // Precompute per-bar GK contributions. + let mut gk = vec![f64::NAN; n]; + for i in 0..n { + let o = open[i]; + let h = high[i]; + let l = low[i]; + let c = close[i]; + if o > 0.0 + && h > 0.0 + && l > 0.0 + && c > 0.0 + && o.is_finite() + && h.is_finite() + && l.is_finite() + && c.is_finite() + { + let u = (h / o).ln(); + let d = (l / o).ln(); + let ci = (c / o).ln(); + gk[i] = 0.5 * (u - d).powi(2) - (2.0 * ln2 - 1.0) * ci * ci; + } + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let slice = &gk[start..=end]; + if slice.iter().all(|v| v.is_finite()) { + let sum: f64 = slice.iter().sum(); + let var = sum / window as f64 * trading_days; + out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN }; + } + } + out +} + +/// Compute the Rogers-Satchell per-bar variance contribution. +fn rs_bar(open: f64, high: f64, low: f64, close: f64) -> f64 { + let u = (high / close).ln(); + let d = (low / close).ln(); + let uo = (high / open).ln(); + let do_ = (low / open).ln(); + u * uo + d * do_ +} + +/// Rolling Rogers-Satchell OHLC realized volatility estimator. +/// +/// Returns a `Vec` of the same length as the inputs. The first `window-1` +/// values are NaN. All four slices must have the same length. +pub fn rogers_satchell_vol( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + window: usize, + trading_days: f64, +) -> Vec { + let n = open.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n { + return out; + } + + // Precompute per-bar RS contributions. + let mut rs = vec![f64::NAN; n]; + for i in 0..n { + let o = open[i]; + let h = high[i]; + let l = low[i]; + let c = close[i]; + if o > 0.0 + && h > 0.0 + && l > 0.0 + && c > 0.0 + && o.is_finite() + && h.is_finite() + && l.is_finite() + && c.is_finite() + { + rs[i] = rs_bar(o, h, l, c); + } + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let slice = &rs[start..=end]; + if slice.iter().all(|v| v.is_finite()) { + let sum: f64 = slice.iter().sum(); + let var = sum / window as f64 * trading_days; + out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN }; + } + } + out +} + +/// Rolling Yang-Zhang OHLC realized volatility estimator. +/// +/// Handles overnight gaps. Returns a `Vec` of the same length as the inputs. +/// The first `window` values are NaN (we need `window` bars plus the prior close +/// for overnight returns, so valid output starts at index `window`). +/// All four slices must have the same length. +pub fn yang_zhang_vol( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + window: usize, + trading_days: f64, +) -> Vec { + let n = open.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n <= window || high.len() != n || low.len() != n || close.len() != n { + return out; + } + + let k = 0.34 / (1.34 + (window as f64 + 1.0) / (window as f64 - 1.0).max(1e-10)); + + // Precompute per-bar components; index 0 has no overnight return. + // overnight[i] = ln(O_i / C_{i-1}), valid for i >= 1 + // openclose[i] = ln(C_i / O_i) + // rs[i] = Rogers-Satchell for bar i + let mut overnight = vec![f64::NAN; n]; + let mut openclose = vec![f64::NAN; n]; + let mut rs = vec![f64::NAN; n]; + + for i in 0..n { + let o = open[i]; + let h = high[i]; + let l = low[i]; + let c = close[i]; + if o > 0.0 + && h > 0.0 + && l > 0.0 + && c > 0.0 + && o.is_finite() + && h.is_finite() + && l.is_finite() + && c.is_finite() + { + openclose[i] = (c / o).ln(); + rs[i] = rs_bar(o, h, l, c); + + if i > 0 { + let prev_c = close[i - 1]; + if prev_c > 0.0 && prev_c.is_finite() { + overnight[i] = (o / prev_c).ln(); + } + } + } + } + + // Valid windows start at index `window` (using bars [end-window+1 .. end], + // all of which have valid overnight returns since they start at index >= 1). + for end in window..n { + let start = end + 1 - window; // start >= 1 because end >= window + + let o_slice = &overnight[start..=end]; + let c_slice = &openclose[start..=end]; + let r_slice = &rs[start..=end]; + + if !o_slice.iter().all(|v| v.is_finite()) + || !c_slice.iter().all(|v| v.is_finite()) + || !r_slice.iter().all(|v| v.is_finite()) + { + continue; + } + + let w = window as f64; + + let o_sum: f64 = o_slice.iter().sum(); + let o_sum_sq: f64 = o_slice.iter().map(|&x| x * x).sum(); + let overnight_var = o_sum_sq / (w - 1.0) - (o_sum / w).powi(2) * w / (w - 1.0); + + let c_sum: f64 = c_slice.iter().sum(); + let c_sum_sq: f64 = c_slice.iter().map(|&x| x * x).sum(); + let openclose_var = c_sum_sq / (w - 1.0) - (c_sum / w).powi(2) * w / (w - 1.0); + + let rs_sum: f64 = r_slice.iter().sum(); + let rs_var = rs_sum / w; + + let yz_var = overnight_var + k * openclose_var + (1.0 - k) * rs_var; + let annualized = yz_var * trading_days; + out[end] = if annualized >= 0.0 { + annualized.sqrt() + } else { + f64::NAN + }; + } + out +} + +/// Summary statistics of realized vol distribution for one window length. +#[derive(Clone, Copy, Debug)] +pub struct VolConeSlice { + pub window: usize, + pub min: f64, + pub p25: f64, + pub median: f64, + pub p75: f64, + pub max: f64, +} + +/// Compute a percentile via linear interpolation on a sorted slice. +/// +/// `sorted` must be non-empty and already sorted ascending. +fn percentile_sorted(sorted: &[f64], p: f64) -> f64 { + let n = sorted.len(); + if n == 1 { + return sorted[0]; + } + let idx = (n - 1) as f64 * p; + let lo = idx.floor() as usize; + let hi = idx.ceil() as usize; + let frac = idx - lo as f64; + sorted[lo] + frac * (sorted[hi] - sorted[lo]) +} + +/// Compute vol cone: distribution of realized vols across multiple window lengths. +/// +/// For each window in `windows`, the close-to-close rolling vol is computed, +/// NaN values are filtered out, and the distribution statistics (min, p25, +/// median, p75, max) are derived via linear interpolation. +pub fn vol_cone(close: &[f64], windows: &[usize], trading_days: f64) -> Vec { + windows + .iter() + .map(|&w| { + let vols = close_to_close_vol(close, w, trading_days); + let mut valid: Vec = vols.into_iter().filter(|v| v.is_finite()).collect(); + valid.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + if valid.is_empty() { + return VolConeSlice { + window: w, + min: f64::NAN, + p25: f64::NAN, + median: f64::NAN, + p75: f64::NAN, + max: f64::NAN, + }; + } + + VolConeSlice { + window: w, + min: valid[0], + p25: percentile_sorted(&valid, 0.25), + median: percentile_sorted(&valid, 0.5), + p75: percentile_sorted(&valid, 0.75), + max: *valid.last().unwrap(), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_prices(n: usize) -> Vec { + // simple synthetic price series + let mut prices = vec![100.0_f64; n]; + for i in 1..n { + prices[i] = prices[i - 1] * (1.0 + 0.01 * (i as f64 % 7_f64 - 3.0) * 0.01); + } + prices + } + + #[test] + fn close_to_close_returns_nans_for_warmup() { + let close = fake_prices(100); + let result = close_to_close_vol(&close, 20, 252.0); + assert_eq!(result.len(), 100); + // first 20 values should be NaN (window-1 of returns warmup + 1 for diff) + for i in 0..20 { + assert!(result[i].is_nan(), "result[{i}] should be NaN"); + } + assert!(result[20].is_finite()); + } + + #[test] + fn parkinson_vol_is_positive() { + let close = fake_prices(100); + let high: Vec = close.iter().map(|&c| c * 1.01).collect(); + let low: Vec = close.iter().map(|&c| c * 0.99).collect(); + let result = parkinson_vol(&high, &low, 20, 252.0); + for v in result.iter().skip(19) { + assert!(v.is_finite() && *v >= 0.0); + } + } + + #[test] + fn vol_cone_is_ordered() { + let close = fake_prices(300); + let cones = vol_cone(&close, &[20, 60], 252.0); + assert_eq!(cones.len(), 2); + for cone in &cones { + assert!(cone.min <= cone.p25); + assert!(cone.p25 <= cone.median); + assert!(cone.median <= cone.p75); + assert!(cone.p75 <= cone.max); + } + } + + #[test] + fn garman_klass_returns_nans_for_warmup() { + let close = fake_prices(50); + let high: Vec = close.iter().map(|&c| c * 1.01).collect(); + let low: Vec = close.iter().map(|&c| c * 0.99).collect(); + let result = garman_klass_vol(&close, &high, &low, &close, 10, 252.0); + assert_eq!(result.len(), 50); + for i in 0..9 { + assert!(result[i].is_nan(), "result[{i}] should be NaN"); + } + assert!(result[9].is_finite()); + } + + #[test] + fn rogers_satchell_returns_nans_for_warmup() { + let close = fake_prices(50); + let high: Vec = close.iter().map(|&c| c * 1.01).collect(); + let low: Vec = close.iter().map(|&c| c * 0.99).collect(); + let result = rogers_satchell_vol(&close, &high, &low, &close, 10, 252.0); + assert_eq!(result.len(), 50); + for i in 0..9 { + assert!(result[i].is_nan(), "result[{i}] should be NaN"); + } + assert!(result[9].is_finite()); + } + + #[test] + fn yang_zhang_returns_nans_for_warmup() { + let close = fake_prices(50); + let high: Vec = close.iter().map(|&c| c * 1.01).collect(); + let low: Vec = close.iter().map(|&c| c * 0.99).collect(); + let result = yang_zhang_vol(&close, &high, &low, &close, 10, 252.0); + assert_eq!(result.len(), 50); + for i in 0..10 { + assert!(result[i].is_nan(), "result[{i}] should be NaN"); + } + assert!(result[10].is_finite()); + } + + #[test] + fn mismatched_lengths_return_all_nan() { + let a = vec![100.0_f64; 20]; + let b = vec![101.0_f64; 15]; // wrong length + let result = parkinson_vol(&a, &b, 5, 252.0); + assert!(result.iter().all(|v| v.is_nan())); + } + + #[test] + fn window_larger_than_data_returns_all_nan() { + let close = fake_prices(10); + let result = close_to_close_vol(&close, 20, 252.0); + assert!(result.iter().all(|v| v.is_nan())); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/options/surface.rs b/ferro-ta-main/crates/ferro_ta_core/src/options/surface.rs new file mode 100644 index 0000000..aec0337 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/options/surface.rs @@ -0,0 +1,269 @@ +//! Smile and surface analytics helpers. + +use super::chain::atm_index; +use super::greeks::model_greeks; +use super::{ChainGreeksContext, OptionContract, OptionEvaluation, OptionKind, PricingModel}; + +/// Smile summary metrics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SmileMetrics { + pub atm_iv: f64, + pub risk_reversal_25d: f64, + pub butterfly_25d: f64, + pub skew_slope: f64, + pub convexity: f64, +} + +/// Linear interpolation helper. +pub fn linear_interpolate(xs: &[f64], ys: &[f64], target: f64) -> f64 { + if xs.len() != ys.len() || xs.is_empty() { + return f64::NAN; + } + if target <= xs[0] { + return ys[0]; + } + for i in 1..xs.len() { + if target <= xs[i] { + let x0 = xs[i - 1]; + let x1 = xs[i]; + let y0 = ys[i - 1]; + let y1 = ys[i]; + let w = if x1 == x0 { + 0.0 + } else { + (target - x0) / (x1 - x0) + }; + return y0 + w * (y1 - y0); + } + } + ys[ys.len() - 1] +} + +/// ATM implied volatility by nearest strike. +pub fn atm_iv(strikes: &[f64], vols: &[f64], reference_price: f64) -> f64 { + if strikes.len() != vols.len() || strikes.is_empty() || !reference_price.is_finite() { + return f64::NAN; + } + atm_index(strikes, reference_price) + .and_then(|idx| vols.get(idx).copied()) + .unwrap_or(f64::NAN) +} + +fn regression_slope(xs: &[f64], ys: &[f64]) -> f64 { + if xs.len() != ys.len() || xs.len() < 2 { + return f64::NAN; + } + let n = xs.len() as f64; + let mean_x = xs.iter().sum::() / n; + let mean_y = ys.iter().sum::() / n; + let mut cov = 0.0; + let mut var = 0.0; + for (&x, &y) in xs.iter().zip(ys.iter()) { + cov += (x - mean_x) * (y - mean_y); + var += (x - mean_x) * (x - mean_x); + } + if var == 0.0 { + f64::NAN + } else { + cov / var + } +} + +fn closest_delta_iv( + strikes: &[f64], + vols: &[f64], + context: ChainGreeksContext, + target_delta: f64, +) -> f64 { + let mut best_iv = f64::NAN; + let mut best_distance = f64::INFINITY; + for (&strike, &vol) in strikes.iter().zip(vols.iter()) { + if !strike.is_finite() || !vol.is_finite() { + continue; + } + let delta = model_greeks(OptionEvaluation { + contract: OptionContract { + model: context.model, + underlying: context.reference_price, + strike, + rate: context.rate, + carry: context.carry, + time_to_expiry: context.time_to_expiry, + kind: context.kind, + }, + volatility: vol, + }) + .delta; + if !delta.is_finite() { + continue; + } + let distance = (delta - target_delta).abs(); + if distance < best_distance { + best_distance = distance; + best_iv = vol; + } + } + best_iv +} + +/// Smile metrics from a single expiry slice. +pub fn smile_metrics( + strikes: &[f64], + vols: &[f64], + reference_price: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + model: PricingModel, +) -> SmileMetrics { + if strikes.len() != vols.len() || strikes.len() < 3 || reference_price <= 0.0 { + return SmileMetrics { + atm_iv: f64::NAN, + risk_reversal_25d: f64::NAN, + butterfly_25d: f64::NAN, + skew_slope: f64::NAN, + convexity: f64::NAN, + }; + } + + let atm_idx = match atm_index(strikes, reference_price) { + Some(idx) => idx, + None => { + return SmileMetrics { + atm_iv: f64::NAN, + risk_reversal_25d: f64::NAN, + butterfly_25d: f64::NAN, + skew_slope: f64::NAN, + convexity: f64::NAN, + } + } + }; + let atm_iv = vols[atm_idx]; + + let call_25 = closest_delta_iv( + strikes, + vols, + ChainGreeksContext { + model, + reference_price, + rate, + carry, + time_to_expiry, + kind: OptionKind::Call, + }, + 0.25, + ); + let put_25 = closest_delta_iv( + strikes, + vols, + ChainGreeksContext { + model, + reference_price, + rate, + carry, + time_to_expiry, + kind: OptionKind::Put, + }, + -0.25, + ); + let risk_reversal_25d = call_25 - put_25; + let butterfly_25d = 0.5 * (call_25 + put_25) - atm_iv; + + let log_moneyness: Vec = strikes + .iter() + .map(|&k| (k / reference_price).ln()) + .collect(); + let skew_slope = regression_slope(&log_moneyness, vols); + let convexity = if atm_idx > 0 && atm_idx + 1 < strikes.len() { + let x0 = log_moneyness[atm_idx - 1]; + let x1 = log_moneyness[atm_idx]; + let x2 = log_moneyness[atm_idx + 1]; + let y0 = vols[atm_idx - 1]; + let y1 = vols[atm_idx]; + let y2 = vols[atm_idx + 1]; + let left = if x1 == x0 { 0.0 } else { (y1 - y0) / (x1 - x0) }; + let right = if x2 == x1 { 0.0 } else { (y2 - y1) / (x2 - x1) }; + right - left + } else { + f64::NAN + }; + + SmileMetrics { + atm_iv, + risk_reversal_25d, + butterfly_25d, + skew_slope, + convexity, + } +} + +/// Term-structure slope from (tenor, atm_iv) points. +pub fn term_structure_slope(tenors: &[f64], atm_ivs: &[f64]) -> f64 { + regression_slope(tenors, atm_ivs) +} + +/// Expected ±1σ move over `days_to_expiry` calendar days. +/// +/// Returns `(lower_move, upper_move)` as absolute changes from `spot`. +/// Example: if spot=100 and upper_move=5.0 then the 1σ upper bound is 105. +/// +/// Uses the log-normal approximation: `spot × e^{±σ√(days/trading_days)} − spot`. +pub fn expected_move( + spot: f64, + iv: f64, + days_to_expiry: f64, + trading_days_per_year: f64, +) -> (f64, f64) { + if !spot.is_finite() + || !iv.is_finite() + || !days_to_expiry.is_finite() + || !trading_days_per_year.is_finite() + || spot <= 0.0 + || iv < 0.0 + || days_to_expiry < 0.0 + || trading_days_per_year <= 0.0 + { + return (f64::NAN, f64::NAN); + } + let sigma_sqrt_t = iv * (days_to_expiry / trading_days_per_year).sqrt(); + let upper = spot * sigma_sqrt_t.exp() - spot; + let lower = spot * (-sigma_sqrt_t).exp() - spot; + (lower, upper) +} + +#[cfg(test)] +mod tests { + use super::{atm_iv, smile_metrics, term_structure_slope}; + use crate::options::PricingModel; + + #[test] + fn atm_selection_works() { + let strikes = [90.0, 100.0, 110.0]; + let vols = [0.24, 0.20, 0.22]; + assert!((atm_iv(&strikes, &vols, 102.0) - 0.20).abs() < 1e-12); + } + + #[test] + fn smile_metrics_are_finite() { + let strikes = [80.0, 90.0, 100.0, 110.0, 120.0]; + let vols = [0.30, 0.25, 0.20, 0.22, 0.27]; + let metrics = smile_metrics( + &strikes, + &vols, + 100.0, + 0.02, + 0.0, + 0.5, + PricingModel::BlackScholes, + ); + assert!(metrics.atm_iv.is_finite()); + assert!(metrics.skew_slope.is_finite()); + } + + #[test] + fn term_slope_is_reasonable() { + let tenors = [0.1, 0.5, 1.0]; + let vols = [0.18, 0.20, 0.22]; + assert!(term_structure_slope(&tenors, &vols) > 0.0); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/overlap.rs b/ferro-ta-main/crates/ferro_ta_core/src/overlap.rs new file mode 100644 index 0000000..1c203de --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/overlap.rs @@ -0,0 +1,1054 @@ +//! Overlap studies — moving averages and trend indicators. +//! +//! All functions return a `Vec` of the same length as the input. +//! Leading values are `f64::NAN` for the warm-up period. + +/// Compute the Simple Moving Average (SMA) over a rolling window. +/// +/// Returns a `Vec` 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`. +pub fn sma(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + sma_into(close, timeperiod, &mut result, 0); + result +} + +/// Write a Simple Moving Average directly into a pre-allocated buffer. +/// +/// Values before `dest_offset + timeperiod - 1` are left untouched. +/// This avoids an intermediate allocation when composing indicators +/// (e.g., Stochastic slow %K and slow %D). +/// +/// # Arguments +/// * `src` - Input price series. +/// * `timeperiod` - Rolling window size (must be >= 1). +/// * `dest` - Output buffer (must be at least `dest_offset + src.len()` long). +/// * `dest_offset` - Starting index in `dest` to write results. +pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: usize) { + let n = src.len(); + if timeperiod < 1 || n < timeperiod { + return; + } + + // Seed the rolling window with a runtime-dispatched reduction. The O(n) + // streaming recurrence below is inherently sequential, so SIMD only ever + // applies to this initial window sum. + let mut window_sum = crate::simd::sum(&src[..timeperiod]); + let tp_f64 = timeperiod as f64; + dest[dest_offset + timeperiod - 1] = window_sum / tp_f64; + + let mut i = timeperiod; + while i + 1 < n { + let old0 = src[i - timeperiod]; + let new0 = src[i]; + window_sum += new0 - old0; + dest[dest_offset + i] = window_sum / tp_f64; + + let old1 = src[i + 1 - timeperiod]; + let new1 = src[i + 1]; + window_sum += new1 - old1; + dest[dest_offset + i + 1] = window_sum / tp_f64; + + i += 2; + } + if i < n { + window_sum += src[i] - src[i - timeperiod]; + dest[dest_offset + i] = window_sum / tp_f64; + } +} + +/// 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]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let k = 2.0 / (timeperiod as f64 + 1.0); + let seed: f64 = close[..timeperiod].iter().sum::() / timeperiod as f64; + result[timeperiod - 1] = seed; + for i in timeperiod..n { + result[i] = (result[i - 1] * (1.0 - k)).mul_add(1.0, close[i] * k); + } + result +} + +/// Compute the Weighted Moving Average (WMA). +/// +/// 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]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64; + let p = timeperiod as f64; + + // Seed: compute T and S for the first window via a runtime-dispatched + // reduction (the streaming recurrence below is sequential). + let (mut t, mut s) = crate::simd::wma_seed(&close[..timeperiod]); + + result[timeperiod - 1] = t / denom; + + let mut i = timeperiod; + while i + 1 < n { + t += p * close[i] - s; + s += close[i] - close[i - timeperiod]; + result[i] = t / denom; + + t += p * close[i + 1] - s; + s += close[i + 1] - close[i + 1 - timeperiod]; + result[i + 1] = t / denom; + + i += 2; + } + if i < n { + t += p * close[i] - s; + result[i] = t / denom; + } + result +} + +/// Compute Bollinger Bands, returning `(upper, middle, lower)`. +/// +/// The middle band is the SMA; upper and lower bands are offset by +/// `nbdevup` and `nbdevdn` standard deviations respectively. Uses +/// Welford's rolling algorithm for numerically stable variance in O(n). +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - SMA / standard deviation window (must be >= 1). +/// * `nbdevup` - Number of standard deviations above the mean for the upper band. +/// * `nbdevdn` - Number of standard deviations below the mean for the lower band. +/// +/// # Returns +/// `(upper, middle, lower)` -- each `Vec` 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, + nbdevup: f64, + nbdevdn: f64, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let nan = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return (nan.clone(), nan.clone(), nan); + } + let mut upper = vec![f64::NAN; n]; + let mut middle = vec![f64::NAN; n]; + let mut lower = vec![f64::NAN; n]; + let p = timeperiod as f64; + + // --- Seed: 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; + } + + 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 { + 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 { + 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) +} + +/// Compute the Moving Average Convergence/Divergence (MACD). +/// +/// `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. +/// +/// # 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, + slowperiod: usize, + signalperiod: usize, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let nan_vec = || vec![f64::NAN; n]; + if fastperiod < 1 || slowperiod < 1 || signalperiod < 1 || fastperiod >= slowperiod { + return (nan_vec(), nan_vec(), nan_vec()); + } + if n < slowperiod { + return (nan_vec(), nan_vec(), nan_vec()); + } + + let kf = 2.0 / (fastperiod as f64 + 1.0); + let ks = 2.0 / (slowperiod as f64 + 1.0); + + // Seed fast EMA from SMA of first fastperiod bars. + let mut fast_val: f64 = close[..fastperiod].iter().sum::() / fastperiod as f64; + // Seed slow EMA from SMA of first slowperiod bars. + let mut slow_val: f64 = close[..slowperiod].iter().sum::() / slowperiod as f64; + + let mut macd_line = nan_vec(); + + // From fastperiod-1 to slowperiod-2: advance fast EMA only. + for &price in close.iter().take(slowperiod - 1).skip(fastperiod) { + fast_val = price * kf + fast_val * (1.0 - kf); + } + + // From fastperiod to slowperiod-1: advance fastEMA and compute initial MACD at slowperiod-1 + // Actually, fast_val currently holds the value for `slowperiod - 2` after `take(slowperiod - 1)` + // So we apply it for `slowperiod - 1`. + fast_val = close[slowperiod - 1] * kf + fast_val * (1.0 - kf); + macd_line[slowperiod - 1] = fast_val - slow_val; + for i in slowperiod..n { + fast_val = close[i] * kf + fast_val * (1.0 - kf); + slow_val = close[i] * ks + slow_val * (1.0 - ks); + macd_line[i] = fast_val - slow_val; + } + + // Signal line: EMA of macd_line, seeded from the first valid macd value. + // The signal line starts producing values after slowperiod - 1 + signalperiod - 1 bars. + let sig_start = slowperiod - 1 + signalperiod - 1; + let mut signal_line = nan_vec(); + let mut histogram = nan_vec(); + + if sig_start >= n { + // If we can't compute signal, TA-Lib clears MACD! + for v in macd_line.iter_mut().take(n) { + *v = f64::NAN; + } + return (macd_line, signal_line, histogram); + } + + let ksig = 2.0 / (signalperiod as f64 + 1.0); + // Seed signal EMA with SMA of the first signalperiod macd values. + let sig_seed: f64 = macd_line[(slowperiod - 1)..(slowperiod - 1 + signalperiod)] + .iter() + .sum::() + / signalperiod as f64; + signal_line[sig_start] = sig_seed; + histogram[sig_start] = macd_line[sig_start] - signal_line[sig_start]; + + for i in (sig_start + 1)..n { + signal_line[i] = macd_line[i] * ksig + signal_line[i - 1] * (1.0 - ksig); + } + for i in (sig_start + 1)..n { + histogram[i] = macd_line[i] - signal_line[i]; + } + + // TA-Lib pads the MACD line itself with NaNs up to `sig_start`! + for v in macd_line.iter_mut().take(sig_start) { + *v = f64::NAN; + } + + (macd_line, signal_line, histogram) +} + +// --------------------------------------------------------------------------- +// DEMA — Double Exponential Moving Average +// --------------------------------------------------------------------------- + +/// Double Exponential Moving Average: `2*EMA - EMA(EMA)`. +pub fn dema(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + let warmup = 2 * (timeperiod - 1); + let ema1 = ema(close, timeperiod); + let ema2 = ema(&ema1, timeperiod); + for i in warmup..n { + if !ema1[i].is_nan() && !ema2[i].is_nan() { + result[i] = 2.0 * ema1[i] - ema2[i]; + } + } + result +} + +// --------------------------------------------------------------------------- +// TEMA — Triple Exponential Moving Average +// --------------------------------------------------------------------------- + +/// Triple Exponential Moving Average: `3*EMA - 3*EMA(EMA) + EMA(EMA(EMA))`. +pub fn tema(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + let warmup = 3 * (timeperiod - 1); + let ema1 = ema(close, timeperiod); + let ema2 = ema(&ema1, timeperiod); + let ema3 = ema(&ema2, timeperiod); + for i in warmup..n { + if !ema1[i].is_nan() && !ema2[i].is_nan() && !ema3[i].is_nan() { + result[i] = 3.0 * ema1[i] - 3.0 * ema2[i] + ema3[i]; + } + } + result +} + +// --------------------------------------------------------------------------- +// TRIMA — Triangular Moving Average +// --------------------------------------------------------------------------- + +/// Triangular Moving Average (triangle-weighted). +pub fn trima(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let half = timeperiod.div_ceil(2); + let mut weights = Vec::with_capacity(timeperiod); + for i in 1..=timeperiod { + let w = if i <= half { i } else { timeperiod + 1 - i }; + weights.push(w as f64); + } + let weight_sum: f64 = weights.iter().sum(); + for i in (timeperiod - 1)..n { + let mut val = 0.0_f64; + for (j, &w) in weights.iter().enumerate() { + val += close[i - (timeperiod - 1 - j)] * w; + } + result[i] = val / weight_sum; + } + result +} + +// --------------------------------------------------------------------------- +// KAMA — Kaufman Adaptive Moving Average +// --------------------------------------------------------------------------- + +/// Kaufman Adaptive Moving Average. +pub fn kama(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let fast_sc = 2.0 / 3.0_f64; + let slow_sc = 2.0 / 31.0_f64; + let mut kama_val = close[timeperiod - 1]; + result[timeperiod - 1] = kama_val; + for i in timeperiod..n { + let direction = (close[i] - close[i - timeperiod]).abs(); + let mut volatility = 0.0_f64; + for j in 1..=timeperiod { + volatility += (close[i - j + 1] - close[i - j]).abs(); + } + let er = if volatility > 0.0 { + direction / volatility + } else { + 0.0 + }; + let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2); + kama_val += sc * (close[i] - kama_val); + result[i] = kama_val; + } + result +} + +// --------------------------------------------------------------------------- +// T3 — Tillson T3 +// --------------------------------------------------------------------------- + +/// Tillson T3: 6x smoothed EMA with volume factor. +pub fn t3(close: &[f64], timeperiod: usize, vfactor: f64) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + let k = 2.0 / (timeperiod as f64 + 1.0); + let v = vfactor; + let c1 = -(v * v * v); + let c2 = 3.0 * v * v + 3.0 * v * v * v; + let c3 = -6.0 * v * v - 3.0 * v - 3.0 * v * v * v; + let c4 = 1.0 + 3.0 * v + v * v * v + 3.0 * v * v; + let warmup = 6 * (timeperiod - 1); + let mut e = [0.0_f64; 6]; + for (i, &price) in close.iter().enumerate() { + if i == 0 { + for ej in e.iter_mut() { + *ej = price; + } + } else { + e[0] += k * (price - e[0]); + for j in 1..6 { + e[j] += k * (e[j - 1] - e[j]); + } + } + if i >= warmup { + result[i] = c1 * e[5] + c2 * e[4] + c3 * e[3] + c4 * e[2]; + } + } + result +} + +// --------------------------------------------------------------------------- +// SAR — Parabolic SAR +// --------------------------------------------------------------------------- + +/// Parabolic SAR. +pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Vec { + let n = high.len(); + if n < 2 { + return vec![f64::NAN; n]; + } + let mut result = vec![f64::NAN; n]; + let mut is_rising = high[1] >= high[0]; + let mut af = acceleration; + let (mut ep, mut sar_val) = if is_rising { + (high[1], low[0]) + } else { + (low[1], high[0]) + }; + result[1] = sar_val; + for i in 2..n { + let prev_sar = sar_val; + sar_val = prev_sar + af * (ep - prev_sar); + if is_rising { + sar_val = sar_val.min(low[i - 1]).min(low[i - 2]); + if low[i] < sar_val { + is_rising = false; + sar_val = ep; + ep = low[i]; + af = acceleration; + } else if high[i] > ep { + ep = high[i]; + af = (af + acceleration).min(maximum); + } + } else { + sar_val = sar_val.max(high[i - 1]).max(high[i - 2]); + if high[i] > sar_val { + is_rising = true; + sar_val = ep; + ep = high[i]; + af = acceleration; + } else if low[i] < ep { + ep = low[i]; + af = (af + acceleration).min(maximum); + } + } + result[i] = sar_val; + } + result +} + +// --------------------------------------------------------------------------- +// SAREXT — Extended Parabolic SAR +// --------------------------------------------------------------------------- + +/// Parabolic SAR Extended with configurable acceleration factors. +#[allow(clippy::too_many_arguments)] +pub fn sarext( + high: &[f64], + low: &[f64], + startvalue: f64, + offsetonreverse: f64, + accelerationinitlong: f64, + accelerationlong: f64, + accelerationmaxlong: f64, + accelerationinitshort: f64, + accelerationshort: f64, + accelerationmaxshort: f64, +) -> Vec { + let n = high.len(); + if n < 2 { + return vec![f64::NAN; n]; + } + let mut result = vec![f64::NAN; n]; + let mut is_rising = high[1] >= high[0]; + let (mut af, mut af_step_cur, mut af_max_cur) = if is_rising { + (accelerationinitlong, accelerationlong, accelerationmaxlong) + } else { + ( + accelerationinitshort, + accelerationshort, + accelerationmaxshort, + ) + }; + let (mut ep, mut sar_val) = if is_rising { + ( + high[1], + if startvalue != 0.0 { + startvalue + } else { + low[0] + }, + ) + } else { + ( + low[1], + if startvalue != 0.0 { + -startvalue + } else { + high[0] + }, + ) + }; + result[1] = sar_val; + for i in 2..n { + let prev_sar = sar_val; + sar_val = prev_sar + af * (ep - prev_sar); + if is_rising { + sar_val = sar_val.min(low[i - 1]).min(low[i - 2]); + if low[i] < sar_val { + is_rising = false; + sar_val = ep + sar_val.abs() * offsetonreverse; + ep = low[i]; + af = accelerationinitshort; + af_step_cur = accelerationshort; + af_max_cur = accelerationmaxshort; + } else if high[i] > ep { + ep = high[i]; + af = (af + af_step_cur).min(af_max_cur); + } + } else { + sar_val = sar_val.max(high[i - 1]).max(high[i - 2]); + if high[i] > sar_val { + is_rising = true; + sar_val = ep - sar_val.abs() * offsetonreverse; + ep = high[i]; + af = accelerationinitlong; + af_step_cur = accelerationlong; + af_max_cur = accelerationmaxlong; + } else if low[i] < ep { + ep = low[i]; + af = (af + af_step_cur).min(af_max_cur); + } + } + result[i] = sar_val; + } + result +} + +// --------------------------------------------------------------------------- +// MAMA — MESA Adaptive Moving Average +// --------------------------------------------------------------------------- + +/// MESA Adaptive Moving Average. Returns `(mama, fama)`. +pub fn mama(close: &[f64], fastlimit: f64, slowlimit: f64) -> (Vec, Vec) { + let n = close.len(); + let lookback = 32; + let mut mama_arr = vec![f64::NAN; n]; + let mut fama_arr = vec![f64::NAN; n]; + if n <= lookback { + return (mama_arr, fama_arr); + } + + let mut smooth = vec![0.0f64; n]; + for i in 0..n { + smooth[i] = if i >= 3 { + (4.0 * close[i] + 3.0 * close[i - 1] + 2.0 * close[i - 2] + close[i - 3]) / 10.0 + } else { + close[i] + }; + } + + let mut detrender = vec![0.0f64; n]; + let mut q1 = vec![0.0f64; n]; + let mut i1 = vec![0.0f64; n]; + let mut ji = vec![0.0f64; n]; + let mut jq = vec![0.0f64; n]; + let mut i2 = vec![0.0f64; n]; + let mut q2 = vec![0.0f64; n]; + let mut re = vec![0.0f64; n]; + let mut im = vec![0.0f64; n]; + let mut period = vec![0.0f64; n]; + let mut phase = vec![0.0f64; n]; + let mut mama_val = close[0]; + let mut fama_val = close[0]; + + for i in 6..n { + let prev_period = period[i - 1].max(1.0); + let alpha = 0.075 * prev_period + 0.54; + detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2] + - 0.5769 * smooth[i - 4] + - 0.0962 * smooth[i - 6]) + * alpha; + if i >= 12 { + q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2] + - 0.5769 * detrender[i - 4] + - 0.0962 * detrender[i - 6]) + * alpha; + } + if i >= 9 { + i1[i] = detrender[i - 3]; + } + if i >= 15 { + ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6]) + * alpha; + } + if i >= 18 { + jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6]) + * alpha; + } + let i2_raw = i1[i] - jq[i]; + let q2_raw = q1[i] + ji[i]; + i2[i] = 0.2 * i2_raw + 0.8 * i2[i - 1]; + q2[i] = 0.2 * q2_raw + 0.8 * q2[i - 1]; + re[i] = 0.2 * (i2[i] * i2[i - 1] + q2[i] * q2[i - 1]) + 0.8 * re[i - 1]; + im[i] = 0.2 * (i2[i] * q2[i - 1] - q2[i] * i2[i - 1]) + 0.8 * im[i - 1]; + let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 { + std::f64::consts::PI * 2.0 / (im[i] / re[i]).atan() + } else { + prev_period + }; + p = p + .clamp(0.67 * prev_period, 1.5 * prev_period) + .clamp(6.0, 50.0); + period[i] = 0.2 * p + 0.8 * prev_period; + phase[i] = if i1[i] != 0.0 { + q1[i].atan2(i1[i]) * 180.0 / std::f64::consts::PI + } else if q1[i] > 0.0 { + 90.0 + } else if q1[i] < 0.0 { + -90.0 + } else { + 0.0 + }; + let mut delta_phase = phase[i - 1] - phase[i]; + if delta_phase < 1.0 { + delta_phase = 1.0; + } + let adaptive_alpha = (fastlimit / delta_phase).clamp(slowlimit, fastlimit); + if i >= lookback { + mama_val = adaptive_alpha * close[i] + (1.0 - adaptive_alpha) * mama_val; + fama_val = 0.5 * adaptive_alpha * mama_val + (1.0 - 0.5 * adaptive_alpha) * fama_val; + mama_arr[i] = mama_val; + fama_arr[i] = fama_val; + } else { + mama_val = close[i]; + fama_val = close[i]; + } + } + (mama_arr, fama_arr) +} + +// --------------------------------------------------------------------------- +// MIDPOINT / MIDPRICE +// --------------------------------------------------------------------------- + +/// Midpoint: `(max(close) + min(close)) / 2` over rolling window. +pub fn midpoint(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let window = &close[(i + 1 - timeperiod)..=i]; + let mx = window.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let mn = window.iter().cloned().fold(f64::INFINITY, f64::min); + result[i] = (mx + mn) / 2.0; + } + result +} + +/// MidPrice: `(highest_high + lowest_low) / 2` over rolling window. +pub fn midprice(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let start = i + 1 - timeperiod; + let mx = high[start..=i] + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let mn = low[start..=i].iter().cloned().fold(f64::INFINITY, f64::min); + result[i] = (mx + mn) / 2.0; + } + result +} + +// --------------------------------------------------------------------------- +// MACDFIX / MACDEXT +// --------------------------------------------------------------------------- + +/// MACD with fixed 12/26 periods. +pub fn macdfix(close: &[f64], signalperiod: usize) -> (Vec, Vec, Vec) { + macd(close, 12, 26, signalperiod) +} + +/// Compute MA by type: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3. +fn compute_ma_by_type(close: &[f64], timeperiod: usize, matype: u8) -> Vec { + match matype { + 0 => sma(close, timeperiod), + 1 => ema(close, timeperiod), + 2 => wma(close, timeperiod), + 3 => dema(close, timeperiod), + 4 => tema(close, timeperiod), + 5 => trima(close, timeperiod), + 6 => kama(close, timeperiod), + 7 => t3(close, timeperiod, 0.7), + _ => sma(close, timeperiod), + } +} + +/// MACD with configurable MA types for fast/slow/signal. +pub fn macdext( + close: &[f64], + fastperiod: usize, + fastmatype: u8, + slowperiod: usize, + slowmatype: u8, + signalperiod: usize, + signalmatype: u8, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]); + if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod { + return nan3(); + } + let fast_ma = compute_ma_by_type(close, fastperiod, fastmatype); + let slow_ma = compute_ma_by_type(close, slowperiod, slowmatype); + let macd_start = slowperiod - 1; + let mut macd_line = vec![f64::NAN; n]; + for i in macd_start..n { + if !fast_ma[i].is_nan() && !slow_ma[i].is_nan() { + macd_line[i] = fast_ma[i] - slow_ma[i]; + } + } + let macd_valid: Vec = macd_line[macd_start..].to_vec(); + let signal_slice = compute_ma_by_type(&macd_valid, signalperiod, signalmatype); + let mut signal_line = vec![f64::NAN; n]; + let warmup = macd_start + signalperiod - 1; + #[allow(clippy::needless_range_loop)] + for i in warmup..n { + let j = i - macd_start; + if j < signal_slice.len() && !signal_slice[j].is_nan() { + signal_line[i] = signal_slice[j]; + } + } + let mut histogram = vec![f64::NAN; n]; + for i in 0..n { + if !macd_line[i].is_nan() && !signal_line[i].is_nan() { + histogram[i] = macd_line[i] - signal_line[i]; + } + } + (macd_line, signal_line, histogram) +} + +// --------------------------------------------------------------------------- +// MA (generic dispatcher) / MAVP (variable period) +// --------------------------------------------------------------------------- + +/// Generic Moving Average. matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3. +pub fn ma(close: &[f64], timeperiod: usize, matype: u8) -> Vec { + compute_ma_by_type(close, timeperiod, matype) +} + +/// Moving Average with Variable Period per bar (SMA over period from periods array). +pub fn mavp(close: &[f64], periods: &[f64], minperiod: usize, maxperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if minperiod == 0 || maxperiod < minperiod { + return result; + } + for i in 0..n { + if i >= periods.len() { + break; + } + let p = (periods[i].round() as usize).clamp(minperiod, maxperiod); + if i + 1 >= p { + let sum: f64 = close[(i + 1 - p)..=i].iter().sum(); + result[i] = sum / p as f64; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sma_basic() { + let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = sma(&prices, 3); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 2.0).abs() < 1e-10); + assert!((result[3] - 3.0).abs() < 1e-10); + assert!((result[4] - 4.0).abs() < 1e-10); + } + + #[test] + fn ema_basic() { + let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = ema(&prices, 3); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 2.0).abs() < 1e-10); // seed = SMA(3) + } + + #[test] + fn wma_basic() { + let prices = vec![1.0, 2.0, 3.0]; + let result = wma(&prices, 3); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + // weights: 1, 2, 3; denom 6 => (1*1 + 2*2 + 3*3)/6 = 14/6 + assert!((result[2] - 14.0 / 6.0).abs() < 1e-10); + } + + #[test] + fn bbands_basic() { + let prices = vec![2.0, 2.0, 2.0, 2.0, 2.0]; + let (upper, middle, lower) = bbands(&prices, 3, 2.0, 2.0); + assert!((middle[2] - 2.0).abs() < 1e-10); + assert!((upper[2] - 2.0).abs() < 1e-10); // std = 0 + assert!((lower[2] - 2.0).abs() < 1e-10); + } + + #[test] + fn 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; + // At scale 1e12, f64 absolute precision is ~2.2e-4; use 1e-3 headroom. + assert!( + (middle[i] - expected_mean).abs() < 1e-3, + "mean mismatch at {i}: got {} expected {}", + middle[i], + expected_mean, + ); + // Bands should be above/below middle. + assert!(upper[i] >= middle[i]); + assert!(lower[i] <= middle[i]); + } + } + + #[test] + fn bbands_edge_cases() { + // timeperiod == 1: every bar should have std = 0, bands == price. + let prices = vec![10.0, 20.0, 30.0]; + let (upper, middle, lower) = bbands(&prices, 1, 2.0, 2.0); + for i in 0..3 { + assert!((middle[i] - prices[i]).abs() < 1e-10); + assert!((upper[i] - prices[i]).abs() < 1e-10); + assert!((lower[i] - prices[i]).abs() < 1e-10); + } + + // Input shorter than timeperiod: all NaN. + let (u, m, l) = bbands(&[1.0, 2.0], 5, 2.0, 2.0); + assert!(u.iter().all(|v| v.is_nan())); + assert!(m.iter().all(|v| v.is_nan())); + assert!(l.iter().all(|v| v.is_nan())); + } + + #[test] + fn macd_basic() { + // 40 bars of linearly increasing prices — MACD line should converge + let prices: Vec = (1..=40).map(|i| i as f64).collect(); + let (macd_line, signal_line, histogram) = macd(&prices, 3, 5, 2); + // TA-Lib pads MACD line with NaN up to sig_start = slowperiod-1 + signalperiod-1 = 5 + for i in 0..5 { + assert!(macd_line[i].is_nan(), "expected NaN at {i}"); + } + // First valid macd bar is at index 5 (sig_start) + assert!(!macd_line[5].is_nan()); + // First valid signal bar is at index 5 + assert!(!signal_line[5].is_nan()); + // histogram = macd - signal + assert!((histogram[5] - (macd_line[5] - signal_line[5])).abs() < 1e-10); + } + + #[test] + fn macd_invalid_params() { + let prices = vec![1.0; 50]; + // fastperiod >= slowperiod should return all-NaN + let (m, s, h) = macd(&prices, 5, 3, 9); + assert!(m.iter().all(|v| v.is_nan())); + assert!(s.iter().all(|v| v.is_nan())); + assert!(h.iter().all(|v| v.is_nan())); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/pattern.rs b/ferro-ta-main/crates/ferro_ta_core/src/pattern.rs new file mode 100644 index 0000000..e50ba19 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/portfolio.rs b/ferro-ta-main/crates/ferro_ta_core/src/portfolio.rs new file mode 100644 index 0000000..83b7671 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/portfolio.rs @@ -0,0 +1,631 @@ +//! 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)"); + #[allow(clippy::needless_range_loop)] + for j in 1..n_assets { + assert!( + data[j].len() == n_bars, + "all columns must have equal length; column 0 has {} but column {} has {}", + n_bars, + j, + data[j].len() + ); + } + + // Means + let mut means = vec![0.0_f64; n_assets]; + for j in 0..n_assets { + means[j] = data[j].iter().sum::() / 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]; + #[allow(clippy::needless_range_loop)] + for j1 in 0..n_assets { + result[j1][j1] = 1.0; + for j2 in (j1 + 1)..n_assets { + let mut cov = 0.0_f64; + for i in 0..n_bars { + cov += (data[j1][i] - means[j1]) * (data[j2][i] - means[j2]); + } + cov /= n_bars as f64; + let denom = stds[j1] * stds[j2]; + let corr = if denom == 0.0 { f64::NAN } else { cov / denom }; + result[j1][j2] = corr; + result[j2][j1] = corr; + } + } + result +} + +// --------------------------------------------------------------------------- +// relative_strength +// --------------------------------------------------------------------------- + +/// Compute relative strength of an asset vs a benchmark. +/// +/// result[i] = cumprod(1 + asset_returns[0..=i]) / cumprod(1 + benchmark_returns[0..=i]) +/// +/// Panics if lengths differ or are zero. +pub fn relative_strength(asset_returns: &[f64], benchmark_returns: &[f64]) -> Vec { + 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(); + #[allow(clippy::needless_range_loop)] + for j in 1..n_sigs { + assert!( + data[j].len() == n_bars, + "all columns must have equal length" + ); + } + let mut result = vec![0.0_f64; n_bars]; + for i in 0..n_bars { + let mut s = 0.0_f64; + for j in 0..n_sigs { + s += data[j][i] * weights[j]; + } + result[i] = s; + } + result +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + const EPS: f64 = 1e-10; + + fn approx_eq(a: f64, b: f64) -> bool { + (a - b).abs() < EPS + } + + // -- portfolio_volatility ------------------------------------------------- + + #[test] + fn test_portfolio_volatility_identity_cov() { + // Identity covariance, equal weights => sqrt(sum(w_i^2)) + let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]]; + let w = vec![0.5, 0.5]; + let vol = portfolio_volatility(&cov, &w); + // w' I w = 0.25 + 0.25 = 0.5, sqrt = 0.7071... + assert!(approx_eq(vol, (0.5_f64).sqrt())); + } + + #[test] + fn test_portfolio_volatility_single_asset() { + let cov = vec![vec![0.04]]; + let w = vec![1.0]; + assert!(approx_eq(portfolio_volatility(&cov, &w), 0.2)); + } + + #[test] + fn test_portfolio_volatility_correlated() { + // Fully correlated: cov = [[0.04, 0.04], [0.04, 0.04]] + let cov = vec![vec![0.04, 0.04], vec![0.04, 0.04]]; + let w = vec![0.5, 0.5]; + // w' Σ w = 0.04, sqrt = 0.2 + let vol = portfolio_volatility(&cov, &w); + assert!(approx_eq(vol, 0.2)); + } + + // -- beta_full ------------------------------------------------------------ + + #[test] + fn test_beta_full_same_series() { + let r = vec![0.01, -0.02, 0.03, -0.01, 0.02]; + assert!(approx_eq(beta_full(&r, &r), 1.0)); + } + + #[test] + fn test_beta_full_double() { + let bench = vec![0.01, -0.02, 0.03, -0.01, 0.02]; + let asset: Vec = 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/ferro-ta-main/crates/ferro_ta_core/src/price_transform.rs b/ferro-ta-main/crates/ferro_ta_core/src/price_transform.rs new file mode 100644 index 0000000..a42f746 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/regime.rs b/ferro-ta-main/crates/ferro_ta_core/src/regime.rs new file mode 100644 index 0000000..27e9260 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/regime.rs @@ -0,0 +1,166 @@ +//! 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/ferro-ta-main/crates/ferro_ta_core/src/resampling.rs b/ferro-ta-main/crates/ferro_ta_core/src/resampling.rs new file mode 100644 index 0000000..f573adf --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/resampling.rs @@ -0,0 +1,277 @@ +//! 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/ferro-ta-main/crates/ferro_ta_core/src/signals.rs b/ferro-ta-main/crates/ferro_ta_core/src/signals.rs new file mode 100644 index 0000000..1c83e86 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/crates/ferro_ta_core/src/simd.rs b/ferro-ta-main/crates/ferro_ta_core/src/simd.rs new file mode 100644 index 0000000..91f78d2 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/simd.rs @@ -0,0 +1,161 @@ +//! Runtime-dispatched SIMD primitives. +//! +//! Each public reduction here is compiled into several CPU-feature-specific +//! variants (baseline, SSE, AVX2/FMA, AVX-512 on x86_64; NEON on aarch64; …) +//! by [`multiversion`]. The fastest variant the *current* CPU supports is +//! chosen at runtime via CPUID. This gives one binary that: +//! +//! * runs on **any** CPU of the target architecture — no illegal-instruction +//! (SIGILL) crashes on pre-AVX2 chips, unlike a static `-C target-cpu=…`; +//! * still uses wide vector units where the hardware has them. +//! +//! The hot loops accumulate into **independent lanes** before a final +//! horizontal combine. That is what lets the optimizer auto-vectorize them: +//! a plain sequential `iter().sum()` is a dependency chain LLVM may not +//! reorder (doing so would change floating-point rounding). As a consequence +//! these results differ from a strict left-to-right sum by a few ULPs — well +//! inside every indicator's documented tolerance. + +/// Number of independent accumulator lanes. Eight `f64` lanes cover the +/// widest target we dispatch to (AVX-512 = 8×f64); narrower targets (AVX2, +/// NEON) simply use a subset. +#[cfg(feature = "simd")] +const LANES: usize = 8; + +/// Sum of a slice of `f64`, runtime-dispatched. +#[cfg(feature = "simd")] +#[multiversion::multiversion(targets = "simd")] +pub(crate) fn sum(data: &[f64]) -> f64 { + let mut acc = [0.0f64; LANES]; + let mut chunks = data.chunks_exact(LANES); + for chunk in &mut chunks { + for (a, &v) in acc.iter_mut().zip(chunk) { + *a += v; + } + } + let remainder: f64 = chunks.remainder().iter().sum(); + remainder + acc.iter().sum::() +} + +/// Pure-scalar fallback when the `simd` feature is disabled. +#[cfg(not(feature = "simd"))] +pub(crate) fn sum(data: &[f64]) -> f64 { + data.iter().sum() +} + +/// Weighted-moving-average seed for the first window. +/// +/// Returns `(t, s)` where `t = Σ data[k] * (k + 1)` (1-based linear weights) +/// and `s = Σ data[k]`. Used to seed the O(n) WMA recurrence. +#[cfg(feature = "simd")] +#[multiversion::multiversion(targets = "simd")] +pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) { + // Lane-local accumulation (same idea as `sum`) so each CPU-feature clone + // can vectorize: `t` weights each value by its 1-based global index. + let mut t_acc = [0.0f64; LANES]; + let mut s_acc = [0.0f64; LANES]; + let mut chunks = data.chunks_exact(LANES); + let mut base = 0.0f64; // global index of this chunk's first element + for chunk in &mut chunks { + for (lane, ((t, s), &v)) in t_acc + .iter_mut() + .zip(s_acc.iter_mut()) + .zip(chunk) + .enumerate() + { + *t += v * (base + lane as f64 + 1.0); + *s += v; + } + base += LANES as f64; + } + let mut t = 0.0; + let mut s = 0.0; + for (i, &v) in chunks.remainder().iter().enumerate() { + t += v * (base + i as f64 + 1.0); + s += v; + } + (t + t_acc.iter().sum::(), s + s_acc.iter().sum::()) +} + +/// Pure-scalar fallback when the `simd` feature is disabled. +#[cfg(not(feature = "simd"))] +pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) { + let mut t = 0.0; + let mut s = 0.0; + for (k, &v) in data.iter().enumerate() { + t += v * (k + 1) as f64; + s += v; + } + (t, s) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Strict sequential reference — the ground truth we compare against. + fn naive_sum(data: &[f64]) -> f64 { + data.iter().sum() + } + + fn naive_wma_seed(data: &[f64]) -> (f64, f64) { + let t = data + .iter() + .enumerate() + .map(|(k, &v)| v * (k + 1) as f64) + .sum(); + let s = data.iter().sum(); + (t, s) + } + + /// Deterministic test vectors spanning the lane boundaries: empty, a + /// partial chunk (< LANES), an exact multiple, and an exact-multiple + + /// remainder. This exercises every branch of the chunked reduction. + fn cases() -> Vec> { + let big: Vec = (0..1000).map(|i| (i as f64) * 0.5 - 123.0).collect(); + vec![ + vec![], + vec![42.0], + vec![1.0, 2.0, 3.0], // < LANES + (1..=8).map(|i| i as f64).collect(), // exactly LANES + (1..=17).map(|i| i as f64).collect(), // LANES*2 + 1 + big, + ] + } + + #[test] + fn sum_matches_sequential_within_tolerance() { + for data in cases() { + let got = sum(&data); + let want = naive_sum(&data); + assert!( + (got - want).abs() <= 1e-9 * want.abs().max(1.0), + "sum mismatch: got {got}, want {want}, len {}", + data.len() + ); + } + } + + #[test] + fn wma_seed_matches_sequential_within_tolerance() { + for data in cases() { + let (t, s) = wma_seed(&data); + let (wt, ws) = naive_wma_seed(&data); + assert!( + (t - wt).abs() <= 1e-9 * wt.abs().max(1.0), + "wma t mismatch: got {t}, want {wt}, len {}", + data.len() + ); + assert!( + (s - ws).abs() <= 1e-9 * ws.abs().max(1.0), + "wma s mismatch: got {s}, want {ws}, len {}", + data.len() + ); + } + } + + #[test] + fn sum_empty_is_zero() { + assert_eq!(sum(&[]), 0.0); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/statistic.rs b/ferro-ta-main/crates/ferro_ta_core/src/statistic.rs new file mode 100644 index 0000000..3ad0987 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/statistic.rs @@ -0,0 +1,492 @@ +//! Statistic functions. + +/// Compute the rolling population standard deviation, scaled by `nbdev`. +/// +/// Uses population variance (`ddof = 0`). Returns `nbdev * stddev` for +/// each window. The first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `real` - Input series. +/// * `timeperiod` - Rolling window size (must be >= 1). +/// * `nbdev` - Multiplier applied to the standard deviation (use 1.0 for raw stddev). +pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec { + let n = real.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let window = &real[i + 1 - timeperiod..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let var: f64 = window.iter().map(|&x| (x - mean).powi(2)).sum::() / timeperiod as f64; + result[i] = var.sqrt() * nbdev; + } + result +} + +/// Rolling population variance, scaled by `nbdev²`. +pub fn var(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec { + let n = real.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let window = &real[i + 1 - timeperiod..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let variance: f64 = + window.iter().map(|&x| (x - mean).powi(2)).sum::() / timeperiod as f64; + result[i] = variance * nbdev * nbdev; + } + result +} + +// --------------------------------------------------------------------------- +// Linear regression helpers +// --------------------------------------------------------------------------- + +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; + } + let period = timeperiod as f64; + let last_x = (timeperiod - 1) as f64; + let sum_x = last_x * period / 2.0; + let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0; + let denom = period * sum_x2 - sum_x * sum_x; + + let mut sum_y: f64 = prices[..timeperiod].iter().sum(); + let mut sum_xy: f64 = prices[..timeperiod] + .iter() + .enumerate() + .map(|(idx, &v)| idx as f64 * v) + .sum(); + + for end in (timeperiod - 1)..n { + let slope = if denom != 0.0 { + (period * sum_xy - sum_x * sum_y) / denom + } else { + 0.0 + }; + let intercept = (sum_y - slope * sum_x) / period; + result[end] = map(slope, intercept); + if end + 1 < n { + let outgoing = prices[end + 1 - timeperiod]; + let incoming = prices[end + 1]; + let prev_sum_y = sum_y; + sum_y = prev_sum_y - outgoing + incoming; + sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming; + } + } + result +} + +/// Linear regression fitted value at the last point of the window. +pub fn linearreg(close: &[f64], timeperiod: usize) -> Vec { + let last_x = if timeperiod > 0 { + (timeperiod - 1) as f64 + } else { + 0.0 + }; + rolling_linreg_apply(close, timeperiod, |slope, intercept| { + intercept + slope * last_x + }) +} + +/// Slope of the rolling linear regression line. +pub fn linearreg_slope(close: &[f64], timeperiod: usize) -> Vec { + rolling_linreg_apply(close, timeperiod, |slope, _| slope) +} + +/// Intercept of the rolling linear regression line. +pub fn linearreg_intercept(close: &[f64], timeperiod: usize) -> Vec { + rolling_linreg_apply(close, timeperiod, |_, intercept| intercept) +} + +/// Angle of the regression line in degrees. +pub fn linearreg_angle(close: &[f64], timeperiod: usize) -> Vec { + rolling_linreg_apply(close, timeperiod, |slope, _| { + slope.atan() * 180.0 / std::f64::consts::PI + }) +} + +/// Time Series Forecast: linear regression extrapolated one period ahead. +pub fn tsf(close: &[f64], timeperiod: usize) -> Vec { + let forecast_x = timeperiod as f64; + rolling_linreg_apply(close, timeperiod, |slope, intercept| { + intercept + slope * forecast_x + }) +} + +// --------------------------------------------------------------------------- +// Beta (rolling, return-based) +// --------------------------------------------------------------------------- + +/// Rolling beta: regression of real1 daily returns on real0 daily returns. +pub fn beta(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec { + let n = real0.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n <= timeperiod { + return result; + } + + let price_return = |curr: f64, prev: f64| -> f64 { + if prev != 0.0 { + curr / prev - 1.0 + } else { + f64::NAN + } + }; + let rx: Vec = real0.windows(2).map(|w| price_return(w[1], w[0])).collect(); + let ry: Vec = real1.windows(2).map(|w| price_return(w[1], w[0])).collect(); + + let period = timeperiod as f64; + let mut sum_rx = 0.0_f64; + let mut sum_ry = 0.0_f64; + let mut sum_rx2 = 0.0_f64; + let mut sum_rxry = 0.0_f64; + let mut invalid = 0usize; + + for idx in 0..timeperiod { + let (ret_x, ret_y) = (rx[idx], ry[idx]); + if ret_x.is_finite() && ret_y.is_finite() { + sum_rx += ret_x; + sum_ry += ret_y; + sum_rx2 += ret_x * ret_x; + sum_rxry += ret_x * ret_y; + } else { + invalid += 1; + } + } + + for end in timeperiod..n { + result[end] = if invalid == 0 { + let denom = period * sum_rx2 - sum_rx * sum_rx; + if denom != 0.0 { + (period * sum_rxry - sum_rx * sum_ry) / denom + } else { + f64::NAN + } + } else { + f64::NAN + }; + + if end + 1 < n { + let out = end - timeperiod; + let (ox, oy) = (rx[out], ry[out]); + if ox.is_finite() && oy.is_finite() { + sum_rx -= ox; + sum_ry -= oy; + sum_rx2 -= ox * ox; + sum_rxry -= ox * oy; + } else { + invalid -= 1; + } + let (ix, iy) = (rx[end], ry[end]); + if ix.is_finite() && iy.is_finite() { + sum_rx += ix; + sum_ry += iy; + sum_rx2 += ix * ix; + sum_rxry += ix * iy; + } else { + invalid += 1; + } + } + } + result +} + +// --------------------------------------------------------------------------- +// Correlation (rolling Pearson) +// --------------------------------------------------------------------------- + +/// Rolling Pearson correlation coefficient between two series. +pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec { + let n = real0.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + + let period = timeperiod as f64; + let mut sum_x: f64 = real0[..timeperiod].iter().sum(); + let mut sum_y: f64 = real1[..timeperiod].iter().sum(); + let mut sum_x2: f64 = real0[..timeperiod].iter().map(|v| v * v).sum(); + let mut sum_y2: f64 = real1[..timeperiod].iter().map(|v| v * v).sum(); + let mut sum_xy: f64 = real0[..timeperiod] + .iter() + .zip(real1[..timeperiod].iter()) + .map(|(&a, &b)| a * b) + .sum(); + + #[allow(clippy::needless_range_loop)] + for end in (timeperiod - 1)..n { + let denom_x = period * sum_x2 - sum_x * sum_x; + let denom_y = period * sum_y2 - sum_y * sum_y; + result[end] = if denom_x > 0.0 && denom_y > 0.0 { + (period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt() + } else { + f64::NAN + }; + + if end + 1 < n { + let out = end + 1 - timeperiod; + let inc = end + 1; + sum_x += real0[inc] - real0[out]; + sum_y += real1[inc] - real1[out]; + sum_x2 += real0[inc] * real0[inc] - real0[out] * real0[out]; + sum_y2 += real1[inc] * real1[inc] - real1[out] * real1[out]; + sum_xy += real0[inc] * real1[inc] - real0[out] * real1[out]; + } + } + result +} + +// --------------------------------------------------------------------------- +// Dynamic Time Warping (DTW) +// --------------------------------------------------------------------------- + +/// Internal helper: build the full DTW accumulated-cost matrix. +/// +/// Local cost: `|s1[i] - s2[j]|` (Euclidean / L1 for 1-D series). +/// This matches the convention used by `dtaidistance.dtw.distance()`. +/// +/// Out-of-band cells (Sakoe-Chiba constraint) are set to `f64::INFINITY`. +fn dtw_matrix(s1: &[f64], s2: &[f64], window: Option) -> Vec> { + let n = s1.len(); + let m = s2.len(); + let mut dp = vec![vec![f64::INFINITY; m]; n]; + for i in 0..n { + // Window convention matches dtaidistance: window=w means |i-j| < w. + // None = unconstrained (full matrix). + let (j_lo, j_hi) = match window { + None => (0, m), + Some(w) => { + let lo = i.saturating_sub(w.saturating_sub(1)); + let hi = i.saturating_add(w).min(m); + (lo, hi) + } + }; + for j in j_lo..j_hi { + // Squared Euclidean local cost — matches dtaidistance convention. + // The final sqrt is applied only once at the top level (not per-step). + let cost = (s1[i] - s2[j]).powi(2); + let prev = if i == 0 && j == 0 { + 0.0 + } else if i == 0 { + dp[0][j - 1] + } else if j == 0 { + dp[i - 1][0] + } else { + dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1]) + }; + dp[i][j] = cost + prev; + } + } + dp +} + +/// Compute the Dynamic Time Warping distance between two 1-D series. +/// +/// Returns the accumulated Euclidean cost along the optimal warping path. +/// Uses `|s1[i] - s2[j]|` as the local cost, matching `dtaidistance` convention. +/// +/// # Arguments +/// * `s1` - First time series. +/// * `s2` - Second time series. +/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained. +/// +/// Returns `f64::NAN` if either input is empty. +pub fn dtw_distance(s1: &[f64], s2: &[f64], window: Option) -> f64 { + if s1.is_empty() || s2.is_empty() { + return f64::NAN; + } + let dp = dtw_matrix(s1, s2, window); + // sqrt applied once at the end — matches dtaidistance.dtw.distance() convention. + dp[s1.len() - 1][s2.len() - 1].sqrt() +} + +/// Compute the DTW distance and the optimal warping path between two 1-D series. +/// +/// The warping path is a `Vec<(usize, usize)>` of `(i, j)` index pairs, +/// starting at `(0, 0)` and ending at `(n-1, m-1)`, monotonically non-decreasing. +/// +/// # Arguments +/// * `s1` - First time series. +/// * `s2` - Second time series. +/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained. +/// +/// Returns `(f64::NAN, vec![])` if either input is empty. +pub fn dtw_path(s1: &[f64], s2: &[f64], window: Option) -> (f64, Vec<(usize, usize)>) { + if s1.is_empty() || s2.is_empty() { + return (f64::NAN, vec![]); + } + let dp = dtw_matrix(s1, s2, window); + let dist = dp[s1.len() - 1][s2.len() - 1].sqrt(); + + // Backtrace from (n-1, m-1) to (0, 0) + let mut path = Vec::new(); + let (mut i, mut j) = (s1.len() - 1, s2.len() - 1); + path.push((i, j)); + while i > 0 || j > 0 { + let (ni, nj) = match (i, j) { + (0, _) => (0, j - 1), + (_, 0) => (i - 1, 0), + _ => { + let diag = dp[i - 1][j - 1]; + let up = dp[i - 1][j]; + let left = dp[i][j - 1]; + let best = diag.min(up).min(left); + if best == diag { + (i - 1, j - 1) + } else if best == up { + (i - 1, j) + } else { + (i, j - 1) + } + } + }; + i = ni; + j = nj; + path.push((i, j)); + } + path.reverse(); + (dist, path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stddev_constant() { + let prices = vec![5.0; 5]; + let result = stddev(&prices, 3, 1.0); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(v.abs() < 1e-10); + } + } + + #[test] + fn dtw_identical_series_is_zero() { + let a = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + assert_eq!(dtw_distance(&a, &a, None), 0.0); + } + + #[test] + fn dtw_known_shifted_series() { + // [0,1,2] vs [1,2,3]: DTW uses squared Euclidean local cost + final sqrt. + // Optimal path (0,0)→(1,0)→(2,1)→(2,2), accumulated cost = 1+0+0+1 = 2, sqrt(2). + // Matches dtaidistance.dtw.distance([0,1,2],[1,2,3]) = 1.4142... + let a = vec![0.0, 1.0, 2.0]; + let b = vec![1.0, 2.0, 3.0]; + let expected = 2.0_f64.sqrt(); + let result = dtw_distance(&a, &b, None); + assert!( + (result - expected).abs() < 1e-12, + "got {result}, expected {expected}" + ); + } + + #[test] + fn dtw_known_even_shift() { + // [0,2,4] vs [1,3,5]: diagonal path, squared costs 1+1+1=3, sqrt(3). + // Matches dtaidistance.dtw.distance([0,2,4],[1,3,5]) = 1.7320... + let a = vec![0.0, 2.0, 4.0]; + let b = vec![1.0, 3.0, 5.0]; + let expected = 3.0_f64.sqrt(); + let result = dtw_distance(&a, &b, None); + assert!( + (result - expected).abs() < 1e-12, + "got {result}, expected {expected}" + ); + } + + #[test] + fn dtw_single_element() { + let a = vec![3.0]; + let b = vec![7.0]; + assert_eq!(dtw_distance(&a, &b, None), 4.0); + } + + #[test] + fn dtw_empty_returns_nan() { + assert!(dtw_distance(&[], &[1.0, 2.0], None).is_nan()); + assert!(dtw_distance(&[1.0, 2.0], &[], None).is_nan()); + } + + #[test] + fn dtw_path_endpoints() { + let a = vec![1.0, 2.0, 3.0, 4.0]; + let b = vec![1.5, 2.5, 3.5, 4.5]; + let (_, path) = dtw_path(&a, &b, None); + assert_eq!(path.first(), Some(&(0, 0))); + assert_eq!(path.last(), Some(&(3, 3))); + } + + #[test] + fn dtw_path_is_monotone() { + let a = vec![1.0, 3.0, 2.0, 5.0, 4.0]; + let b = vec![2.0, 1.0, 4.0, 3.0, 6.0]; + let (_, path) = dtw_path(&a, &b, None); + for k in 1..path.len() { + assert!(path[k].0 >= path[k - 1].0); + assert!(path[k].1 >= path[k - 1].1); + } + } + + #[test] + fn dtw_path_distance_matches_distance_only() { + let a = vec![1.0, 4.0, 2.0, 8.0, 3.0]; + let b = vec![2.0, 3.0, 7.0, 4.0, 5.0]; + let d1 = dtw_distance(&a, &b, None); + let (d2, _) = dtw_path(&a, &b, None); + assert!((d1 - d2).abs() < 1e-12); + } + + #[test] + fn dtw_nan_in_input_propagates() { + // NaN in either input must propagate to the distance (IEEE 754 semantics). + let a = vec![1.0, 2.0, f64::NAN, 4.0]; + let b = vec![1.0, 2.0, 3.0, 4.0]; + assert!(dtw_distance(&a, &b, None).is_nan()); + assert!(dtw_distance(&b, &a, None).is_nan()); + } + + #[test] + fn dtw_is_symmetric() { + let a = vec![1.0, 4.0, 2.0, 8.0, 3.0, 6.0, 5.0]; + let b = vec![2.0, 3.0, 7.0, 4.0, 5.0, 1.0, 9.0]; + let d_ab = dtw_distance(&a, &b, None); + let d_ba = dtw_distance(&b, &a, None); + assert!((d_ab - d_ba).abs() < 1e-12); + } + + #[test] + fn dtw_path_length_bounded() { + // A valid warp path has length between max(n, m) and n + m - 1. + let a: Vec = (0..7).map(|x| x as f64).collect(); + let b: Vec = (0..10).map(|x| (x as f64).sin()).collect(); + let (_, path) = dtw_path(&a, &b, None); + let n = a.len(); + let m = b.len(); + assert!(path.len() >= n.max(m)); + assert!(path.len() <= n + m - 1); + } + + #[test] + fn dtw_window_constrained_ge_unconstrained() { + // window convention matches dtaidistance: Some(w) means |i-j| < w. + // A narrow window restricts warping, so constrained distance >= unconstrained. + let a: Vec = (0..20).map(|x| x as f64).collect(); + let b: Vec = (0..20).map(|x| x as f64 + 3.0).collect(); + let d_full = dtw_distance(&a, &b, None); + let d_narrow = dtw_distance(&a, &b, Some(3)); + assert!(d_narrow >= d_full - 1e-12); + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/streaming.rs b/ferro-ta-main/crates/ferro_ta_core/src/streaming.rs new file mode 100644 index 0000000..fd5d867 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/streaming.rs @@ -0,0 +1,946 @@ +//! 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/ferro-ta-main/crates/ferro_ta_core/src/volatility.rs b/ferro-ta-main/crates/ferro_ta_core/src/volatility.rs new file mode 100644 index 0000000..67778cf --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/volatility.rs @@ -0,0 +1,95 @@ +//! Volatility indicators. + +/// Compute the Average True Range (ATR), Wilder smoothed (TA-Lib compatible). +/// +/// ATR measures market volatility by smoothing the True Range with Wilder's +/// method. Seeded with the SMA of `TR[1..=timeperiod]` (bar 0 is skipped, +/// matching TA-Lib). Returns non-negative values; the first `timeperiod` +/// indices are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Smoothing period (typically 14). +pub fn atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if n <= timeperiod || timeperiod < 1 { + return result; + } + // Seed: SMA of TR[1..=timeperiod] (TA-Lib skips TR[0]). + // Compute TR on-the-fly to avoid a separate Vec allocation. + let mut seed = 0.0_f64; + for i in 1..=timeperiod { + let hl = high[i] - low[i]; + let hpc = (high[i] - close[i - 1]).abs(); + let lpc = (low[i] - close[i - 1]).abs(); + seed += hl.max(hpc).max(lpc); + } + seed /= timeperiod as f64; + result[timeperiod] = seed; + let p = timeperiod as f64; + for i in (timeperiod + 1)..n { + let hl = high[i] - low[i]; + let hpc = (high[i] - close[i - 1]).abs(); + let lpc = (low[i] - close[i - 1]).abs(); + let tr = hl.max(hpc).max(lpc); + result[i] = (result[i - 1] * (p - 1.0) + tr) / p; + } + result +} + +/// Compute the True Range for each bar. +/// +/// `TR = max(H - L, |H - C_prev|, |L - C_prev|)`. For bar 0, TR is +/// simply `H - L` (no previous close available). Returns non-negative +/// values for every bar (no `NaN` warmup). +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if n == 0 { + return result; + } + result[0] = high[0] - low[0]; + for i in 1..n { + let hl = high[i] - low[i]; + let hpc = (high[i] - close[i - 1]).abs(); + let lpc = (low[i] - close[i - 1]).abs(); + result[i] = hl.max(hpc).max(lpc); + } + result +} + +/// Normalized Average True Range: `ATR / close * 100`. +pub fn natr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let atr_vals = atr(high, low, close, timeperiod); + atr_vals + .iter() + .zip(close.iter()) + .map(|(&a, &c)| { + if a.is_nan() || c == 0.0 { + f64::NAN + } else { + a / c * 100.0 + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn atr_nonnegative() { + let h = vec![2.0, 3.0, 4.0, 5.0, 6.0]; + let l = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let c = vec![1.5, 2.5, 3.5, 4.5, 5.5]; + let result = atr(&h, &l, &c, 3); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0); + } + } +} diff --git a/ferro-ta-main/crates/ferro_ta_core/src/volume.rs b/ferro-ta-main/crates/ferro_ta_core/src/volume.rs new file mode 100644 index 0000000..4696a04 --- /dev/null +++ b/ferro-ta-main/crates/ferro_ta_core/src/volume.rs @@ -0,0 +1,193 @@ +//! Volume indicators. + +/// Compute On-Balance Volume (OBV). +/// +/// OBV is a cumulative indicator that adds volume on up-close bars and +/// subtracts volume on down-close bars. Unchanged closes contribute zero. +/// Returns a `Vec` 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]; + if n == 0 { + return result; + } + // result[0] stays 0; accumulation starts from bar 1 + for i in 1..n { + result[i] = result[i - 1] + + if close[i] > close[i - 1] { + volume[i] + } else if close[i] < close[i - 1] { + -volume[i] + } else { + 0.0 + }; + } + result +} + +/// Compute the Money Flow Index (MFI). +/// +/// 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], + close: &[f64], + volume: &[f64], + timeperiod: usize, +) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n <= timeperiod { + return result; + } + + let mut pos_flow = vec![0.0_f64; n]; + let mut neg_flow = vec![0.0_f64; n]; + let mut tp_prev = (high[0] + low[0] + close[0]) / 3.0; + + for i in 1..n { + let tp_cur = (high[i] + low[i] + close[i]) / 3.0; + let rmf = tp_cur * volume[i]; + if tp_cur > tp_prev { + pos_flow[i] = rmf; + } else if tp_cur < tp_prev { + neg_flow[i] = rmf; + } + tp_prev = tp_cur; + } + + // Sliding window sum over timeperiod bars (indices i+1-timeperiod ..= i). + // First valid window: indices 1..=timeperiod. + let mut pos_sum: f64 = pos_flow[1..=timeperiod].iter().sum(); + let mut neg_sum: f64 = neg_flow[1..=timeperiod].iter().sum(); + let mfr = if neg_sum == 0.0 { + f64::MAX + } else { + pos_sum / neg_sum + }; + result[timeperiod] = 100.0 - 100.0 / (1.0 + mfr); + + for i in (timeperiod + 1)..n { + pos_sum += pos_flow[i] - pos_flow[i - timeperiod]; + neg_sum += neg_flow[i] - neg_flow[i - timeperiod]; + let mfr = if neg_sum == 0.0 { + f64::MAX + } else { + pos_sum / neg_sum + }; + result[i] = 100.0 - 100.0 / (1.0 + mfr); + } + result +} + +/// 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::*; + + #[test] + fn obv_up_trend() { + let c = vec![1.0, 2.0, 3.0]; + let v = vec![100.0, 200.0, 300.0]; + let result = obv(&c, &v); + assert!((result[0] - 0.0).abs() < 1e-10); + assert!((result[1] - 200.0).abs() < 1e-10); + assert!((result[2] - 500.0).abs() < 1e-10); + } + + #[test] + fn ad_basic() { + let h = vec![10.0, 12.0, 11.0]; + let l = vec![8.0, 9.0, 9.0]; + let c = vec![9.0, 11.0, 10.0]; + let v = vec![1000.0, 2000.0, 1500.0]; + let result = ad(&h, &l, &c, &v); + assert_eq!(result.len(), 3); + // CLV[0] = ((9-8) - (10-9)) / (10-8) = (1 - 1) / 2 = 0 + assert!((result[0] - 0.0).abs() < 1e-10); + } + + #[test] + fn adosc_basic() { + let n = 30; + let h: Vec = (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; + let high: Vec = (1..=n).map(|i| i as f64 + 0.5).collect(); + let low: Vec = (1..=n).map(|i| i as f64 - 0.5).collect(); + let close: Vec = (1..=n).map(|i| i as f64).collect(); + let volume: Vec = vec![1_000_000.0; n]; + let result = mfi(&high, &low, &close, &volume, 14); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0 && *v <= 100.0, "MFI out of range: {v}"); + } + } +} diff --git a/ferro-ta-main/deny.toml b/ferro-ta-main/deny.toml new file mode 100644 index 0000000..7ef18fb --- /dev/null +++ b/ferro-ta-main/deny.toml @@ -0,0 +1,76 @@ +# cargo-deny configuration +# Run: cargo deny check +# CI: add `cargo install cargo-deny && cargo deny check` to the audit job + +[graph] +# Targets to check — all platforms used in CI +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "wasm32-unknown-unknown", +] + +# --------------------------------------------------------------------------- +# Licenses +# --------------------------------------------------------------------------- +[licenses] +# Confidence threshold for detecting a license (0.0 – 1.0) +confidence-threshold = 0.8 + +# List of explicitly allowed SPDX license identifiers +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-DFS-2016", + "Unicode-3.0", + "Zlib", + "CC0-1.0", +] + +# --------------------------------------------------------------------------- +# Bans — duplicate crates, banned crates +# --------------------------------------------------------------------------- +[bans] +# Deny multiple versions of the same crate (set to "warn" to downgrade) +multiple-versions = "warn" +# Deny wildcard dependencies +wildcards = "deny" +# Skip certain crates that intentionally have multiple versions +skip = [] + +# --------------------------------------------------------------------------- +# Advisories — security vulnerability database +# --------------------------------------------------------------------------- +[advisories] +# Path to local advisory database (leave empty to use the bundled one) +# db-path = "~/.cargo/advisory-db" +db-urls = ["https://github.com/rustsec/advisory-db"] +# Deny known security vulnerabilities +version = 2 +ignore = [ + # pyo3 0.25 advisories — fix is pyo3 >=0.29, which is a large API + # migration (IntoPy/ToPyObject were removed in 0.26). Ignored here + # because ferro-ta does NOT use either affected code path: + # * RUSTSEC-2026-0176 — OOB read in PyList/PyTuple nth/nth_back + # iterators: the crate has zero PyList/PyTuple iterator usage. + # * RUSTSEC-2026-0177 — missing Sync bound on + # PyCFunction::new_closure: the crate uses #[pyfunction], never + # new_closure. + # Tracked for removal once the pyo3 0.29 upgrade lands. + "RUSTSEC-2026-0176", + "RUSTSEC-2026-0177", +] + +# --------------------------------------------------------------------------- +# Sources — only allow crates from crates.io and our own path deps +# --------------------------------------------------------------------------- +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/ferro-ta-main/docs/_static/.gitkeep b/ferro-ta-main/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ferro-ta-main/docs/adjacent_tooling.rst b/ferro-ta-main/docs/adjacent_tooling.rst new file mode 100644 index 0000000..3b0a6e1 --- /dev/null +++ b/ferro-ta-main/docs/adjacent_tooling.rst @@ -0,0 +1,121 @@ +Adjacent Tooling +================ + +These modules are useful, but they are secondary to ferro-ta's core identity as +a Python technical analysis library. + +.. list-table:: + :header-rows: 1 + + * - 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, + curve, and roll utilities. See :doc:`derivatives`. + * - Agent workflow wrappers + - Adjacent + - Tool and workflow helpers for agent-style integrations. See + `docs/agentic.md `_. + * - MCP server + - Experimental or adjacent + - FastMCP-based server exposing selected ferro-ta capabilities to + MCP-compatible clients. See + `docs/mcp.md `_. + * - WASM package + - Experimental + - Browser and Node.js package with a smaller indicator subset. See + `wasm/README.md `_. + * - GPU backend + - Experimental + - Optional PyTorch-backed acceleration for a limited subset of indicators. + See `docs/gpu-backend.md `_. + * - Plugin system + - Experimental + - 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 +----------------------- + +When evaluating ferro-ta: + +- Start with the core library docs, migration guide, support matrix, and benchmarks. +- Treat adjacent tooling as opt-in layers, not as proof that the core indicator + library is broader or more stable than it is. +- Check the release notes and stability policy before depending on experimental + surfaces in production. diff --git a/ferro-ta-main/docs/agentic.md b/ferro-ta-main/docs/agentic.md new file mode 100644 index 0000000..20e5a94 --- /dev/null +++ b/ferro-ta-main/docs/agentic.md @@ -0,0 +1,203 @@ +# Agentic Workflow and Tools + +ferro-ta provides stable tool wrappers and a workflow orchestrator that make +it easy to integrate with AI agents, LangChain, LlamaIndex, or any +framework that supports function calling. + +--- + +## Overview + +The agentic API consists of two modules: + +| Module | Purpose | +|--------|---------| +| `ferro_ta.tools` | Stable, documented functions for agent wrapping | +| `ferro_ta.workflow` | End-to-end pipeline: indicators → strategy → alerts | + +--- + +## `ferro_ta.tools` — Tool wrappers + +```python +from ferro_ta.tools import compute_indicator, run_backtest, list_indicators, describe_indicator +import numpy as np + +close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 200)) * 100 + +# Compute any indicator by name +sma = compute_indicator("SMA", close, timeperiod=20) +rsi = compute_indicator("RSI", close, timeperiod=14) +bb = compute_indicator("BBANDS", close, timeperiod=20) # returns dict + +# Run a backtest +summary = run_backtest("rsi_30_70", close) +print(f"Final equity: {summary['final_equity']:.4f}") +print(f"Trades: {summary['n_trades']}") + +# List all indicators +names = list_indicators() # sorted list of strings + +# Describe an indicator (returns first paragraph of docstring) +desc = describe_indicator("RSI") +``` + +### Function signatures + +```python +def compute_indicator(name: str, *args, **kwargs) -> ndarray | dict: + ... + +def run_backtest(strategy: str, close, commission_per_trade=0.0, slippage_bps=0.0, **kwargs) -> dict: + ... + +def list_indicators() -> list[str]: + ... + +def describe_indicator(name: str) -> str: + ... +``` + +--- + +## `ferro_ta.workflow` — End-to-end pipeline + +```python +from ferro_ta.workflow import Workflow +import numpy as np + +rng = np.random.default_rng(42) +close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100 + +result = ( + Workflow() + .add_indicator("sma_20", "SMA", timeperiod=20) + .add_indicator("rsi_14", "RSI", timeperiod=14) + .add_strategy("rsi_30_70") + .add_alert("rsi_14", level=30.0, direction=-1) # alert when RSI crosses below 30 + .run(close) +) + +print(result.keys()) +# dict_keys(['sma_20', 'rsi_14', 'backtest', 'alert_rsi_14_30_-1']) +``` + +### Functional interface + +```python +from ferro_ta.workflow import run_pipeline + +result = run_pipeline( + close, + indicators={ + "sma_20": {"name": "SMA", "timeperiod": 20}, + "rsi_14": {"name": "RSI", "timeperiod": 14}, + }, + strategy="rsi_30_70", + alert_indicator="rsi_14", + alert_level=30.0, + alert_direction=-1, +) +``` + +--- + +## LangChain integration + +Wrap the tools as LangChain `Tool` objects: + +```python +from langchain.tools import Tool +from ferro_ta.tools import compute_indicator, run_backtest, list_indicators +import numpy as np +import json + +def _compute_tool(input_str: str) -> str: + """Parse JSON input and compute an indicator.""" + args = json.loads(input_str) + name = args.pop("name") + close = np.asarray(args.pop("close"), dtype=np.float64) + result = compute_indicator(name, close, **args) + if isinstance(result, dict): + return json.dumps({k: v.tolist() for k, v in result.items()}) + return json.dumps(result.tolist()) + +def _backtest_tool(input_str: str) -> str: + args = json.loads(input_str) + close = np.asarray(args.pop("close"), dtype=np.float64) + strategy = args.pop("strategy", "rsi_30_70") + summary = run_backtest(strategy, close, **args) + return json.dumps(summary) + +tools = [ + Tool( + name="compute_indicator", + func=_compute_tool, + description=( + 'Compute a technical indicator. Input JSON: {"name": "SMA", ' + '"close": [...], "timeperiod": 14}' + ), + ), + Tool( + name="run_backtest", + func=_backtest_tool, + description=( + 'Run a backtest. Input JSON: {"strategy": "rsi_30_70", ' + '"close": [...]}' + ), + ), + Tool( + name="list_indicators", + func=lambda _: json.dumps(list_indicators()), + description="List all available indicator names. No input required.", + ), +] +``` + +--- + +## Scheduling + +### Run once + +```python +python examples/run_workflow.py +``` + +### Run every N minutes (cron) + +Add to your crontab: + +``` +*/15 * * * * /usr/bin/python /path/to/examples/run_workflow.py >> /var/log/ferro_ta.log 2>&1 +``` + +### Run on a schedule with `schedule` library + +```python +import schedule +import time + +def job(): + import numpy as np + from ferro_ta.workflow import run_pipeline + # fetch latest prices here ... + close = np.ones(100) # replace with real data + result = run_pipeline(close, indicators={"rsi": {"name": "RSI", "timeperiod": 14}}) + print(result) + +schedule.every(15).minutes.do(job) +while True: + schedule.run_pending() + time.sleep(1) +``` + +--- + +## See also + +- `ferro_ta.tools` — module source. +- `ferro_ta.workflow` — module source. +- `docs/mcp.md` — MCP server for MCP-compatible clients. +- `ferro_ta.backtest` — backtest harness. +- `ferro_ta.registry` — indicator registry. diff --git a/ferro-ta-main/docs/api/analysis.rst b/ferro-ta-main/docs/api/analysis.rst new file mode 100644 index 0000000..a04dbe8 --- /dev/null +++ b/ferro-ta-main/docs/api/analysis.rst @@ -0,0 +1,22 @@ +Analysis Modules +================ + +.. automodule:: ferro_ta.analysis.options + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: ferro_ta.analysis.futures + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: ferro_ta.analysis.options_strategy + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: ferro_ta.analysis.derivatives_payoff + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/batch.rst b/ferro-ta-main/docs/api/batch.rst new file mode 100644 index 0000000..48435e2 --- /dev/null +++ b/ferro-ta-main/docs/api/batch.rst @@ -0,0 +1,7 @@ +Batch API +========= + +.. automodule:: ferro_ta.batch + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/cycle.rst b/ferro-ta-main/docs/api/cycle.rst new file mode 100644 index 0000000..2e3d276 --- /dev/null +++ b/ferro-ta-main/docs/api/cycle.rst @@ -0,0 +1,7 @@ +Cycle +===== + +.. automodule:: ferro_ta.cycle + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/exceptions.rst b/ferro-ta-main/docs/api/exceptions.rst new file mode 100644 index 0000000..9af9aaf --- /dev/null +++ b/ferro-ta-main/docs/api/exceptions.rst @@ -0,0 +1,8 @@ +Exceptions and validation +========================= + +.. automodule:: ferro_ta.exceptions + :members: + :undoc-members: + :show-inheritance: + :exclude-members: code, suggestion diff --git a/ferro-ta-main/docs/api/extended.rst b/ferro-ta-main/docs/api/extended.rst new file mode 100644 index 0000000..ab67c3e --- /dev/null +++ b/ferro-ta-main/docs/api/extended.rst @@ -0,0 +1,7 @@ +Extended +======== + +.. automodule:: ferro_ta.extended + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/index.rst b/ferro-ta-main/docs/api/index.rst new file mode 100644 index 0000000..00b61ad --- /dev/null +++ b/ferro-ta-main/docs/api/index.rst @@ -0,0 +1,20 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 1 + + exceptions + overlap + momentum + volume + volatility + statistic + price_transform + pattern + cycle + math_ops + extended + streaming + batch + analysis diff --git a/ferro-ta-main/docs/api/math_ops.rst b/ferro-ta-main/docs/api/math_ops.rst new file mode 100644 index 0000000..f14af9e --- /dev/null +++ b/ferro-ta-main/docs/api/math_ops.rst @@ -0,0 +1,7 @@ +Math Ops +======== + +.. automodule:: ferro_ta.math_ops + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/momentum.rst b/ferro-ta-main/docs/api/momentum.rst new file mode 100644 index 0000000..a0a13ab --- /dev/null +++ b/ferro-ta-main/docs/api/momentum.rst @@ -0,0 +1,7 @@ +Momentum +======== + +.. automodule:: ferro_ta.momentum + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/overlap.rst b/ferro-ta-main/docs/api/overlap.rst new file mode 100644 index 0000000..b4e5ec7 --- /dev/null +++ b/ferro-ta-main/docs/api/overlap.rst @@ -0,0 +1,7 @@ +Overlap Studies +=============== + +.. automodule:: ferro_ta.overlap + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/pattern.rst b/ferro-ta-main/docs/api/pattern.rst new file mode 100644 index 0000000..2a2f8dc --- /dev/null +++ b/ferro-ta-main/docs/api/pattern.rst @@ -0,0 +1,7 @@ +Pattern +======= + +.. automodule:: ferro_ta.pattern + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/price_transform.rst b/ferro-ta-main/docs/api/price_transform.rst new file mode 100644 index 0000000..b2d4467 --- /dev/null +++ b/ferro-ta-main/docs/api/price_transform.rst @@ -0,0 +1,7 @@ +Price Transform +=============== + +.. automodule:: ferro_ta.price_transform + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/statistic.rst b/ferro-ta-main/docs/api/statistic.rst new file mode 100644 index 0000000..30b4d1e --- /dev/null +++ b/ferro-ta-main/docs/api/statistic.rst @@ -0,0 +1,7 @@ +Statistic +========= + +.. automodule:: ferro_ta.statistic + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/streaming.rst b/ferro-ta-main/docs/api/streaming.rst new file mode 100644 index 0000000..ebf127e --- /dev/null +++ b/ferro-ta-main/docs/api/streaming.rst @@ -0,0 +1,7 @@ +Streaming +========= + +.. automodule:: ferro_ta.streaming + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/volatility.rst b/ferro-ta-main/docs/api/volatility.rst new file mode 100644 index 0000000..c7eee2a --- /dev/null +++ b/ferro-ta-main/docs/api/volatility.rst @@ -0,0 +1,7 @@ +Volatility +========== + +.. automodule:: ferro_ta.volatility + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api/volume.rst b/ferro-ta-main/docs/api/volume.rst new file mode 100644 index 0000000..9388a18 --- /dev/null +++ b/ferro-ta-main/docs/api/volume.rst @@ -0,0 +1,7 @@ +Volume +====== + +.. automodule:: ferro_ta.volume + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/api_manifest.json b/ferro-ta-main/docs/api_manifest.json new file mode 100644 index 0000000..0ff3fea --- /dev/null +++ b/ferro-ta-main/docs/api_manifest.json @@ -0,0 +1,7118 @@ +{ + "surfaces": { + "python": { + "indicator_count": 211, + "method_count": 467, + "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": "BATCH_DTW", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "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": "DTW", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "DTW_DISTANCE", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "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": "stock_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": "strategy_value", + "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": "ExtendedGreeks", + "category": "options", + "module": "ferro_ta.analysis.options", + "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": "VolCone", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "american_option_price", + "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": "close_to_close_vol", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "digital_option_greeks", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "digital_option_price", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "early_exercise_premium", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "expected_move", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "extended_greeks", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "garman_klass_vol", + "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": "parkinson_vol", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "put_call_parity_deviation", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "rogers_satchell_vol", + "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": "vol_cone", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "yang_zhang_vol", + "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": "BATCH_DTW", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "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": "DTW", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "DTW_DISTANCE", + "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": 351, + "functions": [ + { + "module": "aggregation", + "function": "aggregate_tick_bars", + "file": "aggregation.rs" + }, + { + "module": "aggregation", + "function": "aggregate_time_bars", + "file": "aggregation.rs" + }, + { + "module": "aggregation", + "function": "aggregate_volume_bars_ticks", + "file": "aggregation.rs" + }, + { + "module": "alerts", + "function": "check_cross", + "file": "alerts.rs" + }, + { + "module": "alerts", + "function": "check_threshold", + "file": "alerts.rs" + }, + { + "module": "alerts", + "function": "collect_alert_bars", + "file": "alerts.rs" + }, + { + "module": "attribution", + "function": "extract_trades", + "file": "attribution.rs" + }, + { + "module": "attribution", + "function": "monthly_contribution", + "file": "attribution.rs" + }, + { + "module": "attribution", + "function": "signal_attribution", + "file": "attribution.rs" + }, + { + "module": "attribution", + "function": "trade_stats", + "file": "attribution.rs" + }, + { + "module": "backtest", + "function": "backtest_core", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "backtest_multi_asset_core", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "backtest_ohlcv_core", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "close_position", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "commission_fraction", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "compute_performance_metrics", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "extract_trades_ohlcv", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "half_kelly_fraction", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "kelly_formula", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "kelly_fraction", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "lcg_index", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "lcg_next", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "macd_crossover_signals", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "monte_carlo_bootstrap", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "nan_to_num", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "new", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "new", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "on_bar", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "reset", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "resolve_commission_model", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "rsi_threshold_signals", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "single_asset_backtest", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "sma_crossover_signals", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "summary", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "walk_forward_indices", + "file": "backtest.rs" + }, + { + "module": "batch", + "function": "batch_adx", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_atr", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_ema", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_rsi", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_sma", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_stoch", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "run_close_indicators", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "run_hlc_indicators", + "file": "batch.rs" + }, + { + "module": "chunked", + "function": "forward_fill_nan", + "file": "chunked.rs" + }, + { + "module": "chunked", + "function": "make_chunk_ranges", + "file": "chunked.rs" + }, + { + "module": "chunked", + "function": "stitch_chunks", + "file": "chunked.rs" + }, + { + "module": "chunked", + "function": "trim_overlap", + "file": "chunked.rs" + }, + { + "module": "commission", + "function": "cost_fraction", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "equity_delivery_india", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "equity_intraday_india", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "from_json", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "futures_india", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "options_india", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "proportional", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "short_borrow_cost", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "to_json", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "total_cost", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "zero", + "file": "commission.rs" + }, + { + "module": "crypto", + "function": "continuous_bar_labels", + "file": "crypto.rs" + }, + { + "module": "crypto", + "function": "funding_cumulative_pnl", + "file": "crypto.rs" + }, + { + "module": "crypto", + "function": "mark_session_boundaries", + "file": "crypto.rs" + }, + { + "module": "currency", + "function": "format", + "file": "currency.rs" + }, + { + "module": "currency", + "function": "from_code", + "file": "currency.rs" + }, + { + "module": "cycle", + "function": "compute_ht_core", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_dcperiod", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_dcphase", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_phasor", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_sine", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_trendline", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_trendmode", + "file": "cycle.rs" + }, + { + "module": "extended", + "function": "chandelier_exit", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "choppiness_index", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "donchian", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "hull_ma", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "ichimoku", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "keltner_channels", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "pivot_points", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "supertrend", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "vwap", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "vwma", + "file": "extended.rs" + }, + { + "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": "add", + "file": "math.rs" + }, + { + "module": "math", + "function": "div", + "file": "math.rs" + }, + { + "module": "math", + "function": "max", + "file": "math.rs" + }, + { + "module": "math", + "function": "min", + "file": "math.rs" + }, + { + "module": "math", + "function": "mult", + "file": "math.rs" + }, + { + "module": "math", + "function": "sliding_max", + "file": "math.rs" + }, + { + "module": "math", + "function": "sliding_min", + "file": "math.rs" + }, + { + "module": "math", + "function": "sub", + "file": "math.rs" + }, + { + "module": "math", + "function": "sum", + "file": "math.rs" + }, + { + "module": "math_ops", + "function": "rolling_max", + "file": "math_ops.rs" + }, + { + "module": "math_ops", + "function": "rolling_maxindex", + "file": "math_ops.rs" + }, + { + "module": "math_ops", + "function": "rolling_min", + "file": "math_ops.rs" + }, + { + "module": "math_ops", + "function": "rolling_minindex", + "file": "math_ops.rs" + }, + { + "module": "math_ops", + "function": "rolling_sum", + "file": "math_ops.rs" + }, + { + "module": "momentum", + "function": "adx", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "adx_all", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "adxr", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "apo", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "aroon", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "aroonosc", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "bop", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "cci", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "cmo", + "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": "ppo", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "roc", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "rocp", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "rocr", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "rocr100", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "rsi", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "stoch", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "stochrsi", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "trix", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "ultosc", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "willr", + "file": "momentum.rs" + }, + { + "module": "options.american", + "function": "american_price_baw", + "file": "options/american.rs" + }, + { + "module": "options.american", + "function": "early_exercise_premium", + "file": "options/american.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.digital", + "function": "digital_greeks", + "file": "options/digital.rs" + }, + { + "module": "options.digital", + "function": "digital_price", + "file": "options/digital.rs" + }, + { + "module": "options.greeks", + "function": "black_76_greeks", + "file": "options/greeks.rs" + }, + { + "module": "options.greeks", + "function": "black_scholes_extended_greeks", + "file": "options/greeks.rs" + }, + { + "module": "options.greeks", + "function": "black_scholes_greeks", + "file": "options/greeks.rs" + }, + { + "module": "options.greeks", + "function": "model_extended_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.payoff", + "function": "aggregate_greeks_dense", + "file": "options/payoff.rs" + }, + { + "module": "options.payoff", + "function": "strategy_payoff_dense", + "file": "options/payoff.rs" + }, + { + "module": "options.payoff", + "function": "strategy_value_dense", + "file": "options/payoff.rs" + }, + { + "module": "options.payoff", + "function": "strategy_value_grid", + "file": "options/payoff.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.pricing", + "function": "put_call_parity_deviation", + "file": "options/pricing.rs" + }, + { + "module": "options.realized_vol", + "function": "close_to_close_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "garman_klass_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "parkinson_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "rogers_satchell_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "vol_cone", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "yang_zhang_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.surface", + "function": "atm_iv", + "file": "options/surface.rs" + }, + { + "module": "options.surface", + "function": "expected_move", + "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": "dema", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "ema", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "kama", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "ma", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "macd", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "macdext", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "macdfix", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "mama", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "mavp", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "midpoint", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "midprice", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "sar", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "sarext", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "sma", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "sma_into", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "t3", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "tema", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "trima", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "wma", + "file": "overlap.rs" + }, + { + "module": "pattern", + "function": "body_size", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "candle_range", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl2crows", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3blackcrows", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3inside", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3linestrike", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3outside", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3starsinsouth", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3whitesoldiers", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlabandonedbaby", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdladvanceblock", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlbelthold", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlbreakaway", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlclosingmarubozu", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlconcealbabyswall", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlcounterattack", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdldarkcloudcover", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdldoji", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdldojistar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdldragonflydoji", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlengulfing", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdleveningdojistar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdleveningstar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlgapsidesidewhite", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlgravestonedoji", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhammer", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhangingman", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlharami", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlharamicross", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhighwave", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhikkake", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhikkakemod", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhomingpigeon", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlidentical3crows", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlinneck", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlinvertedhammer", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlkicking", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlkickingbylength", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlladderbottom", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdllongleggeddoji", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdllongline", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmarubozu", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmatchinglow", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmathold", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmorningdojistar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmorningstar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlonneck", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlpiercing", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlrickshawman", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlrisefall3methods", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlseparatinglines", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlshootingstar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlshortline", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlspinningtop", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlstalledpattern", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlsticksandwich", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdltakuri", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdltasukigap", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlthrusting", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdltristar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlunique3river", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlupsidegap2crows", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlxsidegap3methods", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "is_bearish", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "is_bullish", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "lower_shadow", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "upper_shadow", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "validate_ohlc", + "file": "pattern.rs" + }, + { + "module": "portfolio", + "function": "beta_full", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "compose_weighted", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "correlation_matrix", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "drawdown_series", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "portfolio_volatility", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "ratio", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "relative_strength", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "rolling_beta", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "spread", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "zscore_series", + "file": "portfolio.rs" + }, + { + "module": "price_transform", + "function": "avgprice", + "file": "price_transform.rs" + }, + { + "module": "price_transform", + "function": "medprice", + "file": "price_transform.rs" + }, + { + "module": "price_transform", + "function": "typprice", + "file": "price_transform.rs" + }, + { + "module": "price_transform", + "function": "wclprice", + "file": "price_transform.rs" + }, + { + "module": "regime", + "function": "detect_breaks_cusum", + "file": "regime.rs" + }, + { + "module": "regime", + "function": "regime_adx", + "file": "regime.rs" + }, + { + "module": "regime", + "function": "regime_combined", + "file": "regime.rs" + }, + { + "module": "regime", + "function": "rolling_variance_break", + "file": "regime.rs" + }, + { + "module": "resampling", + "function": "ohlcv_agg", + "file": "resampling.rs" + }, + { + "module": "resampling", + "function": "volume_bars", + "file": "resampling.rs" + }, + { + "module": "signals", + "function": "bottom_n_indices", + "file": "signals.rs" + }, + { + "module": "signals", + "function": "compose_rank", + "file": "signals.rs" + }, + { + "module": "signals", + "function": "rank_values", + "file": "signals.rs" + }, + { + "module": "signals", + "function": "top_n_indices", + "file": "signals.rs" + }, + { + "module": "statistic", + "function": "beta", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "correl", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "dtw_distance", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "dtw_path", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "linearreg", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "linearreg_angle", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "linearreg_intercept", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "linearreg_slope", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "stddev", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "tsf", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "var", + "file": "statistic.rs" + }, + { + "module": "streaming", + "function": "fast_period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "signal_period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "slow_period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "volatility", + "function": "atr", + "file": "volatility.rs" + }, + { + "module": "volatility", + "function": "natr", + "file": "volatility.rs" + }, + { + "module": "volatility", + "function": "trange", + "file": "volatility.rs" + }, + { + "module": "volume", + "function": "ad", + "file": "volume.rs" + }, + { + "module": "volume", + "function": "adosc", + "file": "volume.rs" + }, + { + "module": "volume", + "function": "mfi", + "file": "volume.rs" + }, + { + "module": "volume", + "function": "obv", + "file": "volume.rs" + } + ] + }, + "wasm_node": { + "export_count": 222, + "exports": [ + "ad", + "adosc", + "adx", + "adx_all", + "adxr", + "aggregate_greeks_dense", + "aggregate_tick_bars", + "aggregate_time_bars", + "aggregate_volume_bars_ticks", + "american_price", + "annualized_basis", + "apo", + "aroon", + "aroonosc", + "atm_index", + "atm_iv", + "atr", + "avgprice", + "back_adjusted_continuous", + "backtest_core", + "batch_adx", + "batch_atr", + "batch_ema", + "batch_rsi", + "batch_sma", + "batch_stoch", + "bbands", + "beta_full", + "beta_rolling", + "black_76_greeks", + "black_76_price", + "black_scholes_greeks", + "black_scholes_price", + "bop", + "bottom_n_indices", + "calendar_spreads", + "carry_spread", + "cci", + "chandelier_exit", + "check_cross", + "check_threshold", + "choppiness_index", + "close_to_close_vol", + "cmo", + "collect_alert_bars", + "compose_rank", + "compose_weighted", + "compute_performance_metrics", + "continuous_bar_labels", + "correl", + "correlation_matrix", + "curve_slope", + "curve_summary", + "dema", + "detect_breaks_cusum", + "digital_greeks", + "digital_price", + "donchian", + "drawdown_series", + "dtw_distance", + "dx", + "early_exercise_premium", + "ema", + "exchange_charges_rate", + "expected_move", + "extended_greeks", + "extract_trades", + "fast_period", + "flat_per_order", + "forward_fill_nan", + "funding_cumulative_pnl", + "futures_basis", + "garman_klass_vol", + "gst_rate", + "half_kelly_fraction", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendline", + "ht_trendmode", + "hull_ma", + "ichimoku", + "implied_carry_rate", + "implied_volatility", + "iv_percentile", + "iv_rank", + "iv_zscore", + "kama", + "kelly_fraction", + "keltner_channels", + "label_moneyness", + "linear_interpolate", + "linearreg", + "linearreg_angle", + "linearreg_intercept", + "linearreg_slope", + "lot_size", + "ma", + "macd", + "macd_crossover_signals", + "macdfix", + "make_chunk_ranges", + "mama", + "mark_session_boundaries", + "math_add", + "math_div", + "math_mult", + "math_sub", + "mavp", + "max_brokerage", + "medprice", + "mfi", + "midpoint", + "midprice", + "minus_di", + "minus_dm", + "model_greeks", + "model_price", + "model_theta", + "mom", + "monte_carlo_bootstrap", + "monthly_contribution", + "natr", + "new", + "obv", + "ohlcv_agg", + "parity_gap", + "parkinson_vol", + "per_lot", + "period", + "pivot_points", + "plus_di", + "plus_dm", + "portfolio_volatility", + "ppo", + "price_lower_bound", + "price_upper_bound", + "put_call_parity_deviation", + "rank_series", + "rank_values", + "rate_of_value", + "ratio", + "ratio_adjusted_continuous", + "regime_adx", + "regime_combined", + "regulatory_charges_rate", + "relative_strength", + "roc", + "rocp", + "rocr", + "rocr100", + "rogers_satchell_vol", + "roll_yield", + "rolling_beta", + "rolling_max", + "rolling_maxindex", + "rolling_min", + "rolling_minindex", + "rolling_sum", + "rolling_variance_break", + "rsi", + "rsi_threshold_signals", + "sar", + "select_strike_by_offset", + "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", + "signal_period", + "single_asset_backtest", + "slow_period", + "sma", + "sma_crossover_signals", + "spread", + "stamp_duty_rate", + "stddev", + "stitch_chunks", + "stoch", + "stochf", + "stochrsi", + "strategy_payoff_dense", + "strategy_value_grid", + "stt_on_buy", + "stt_on_sell", + "stt_rate", + "supertrend", + "synthetic_forward", + "synthetic_spot", + "t3", + "tema", + "term_structure_slope", + "top_n_indices", + "trade_stats", + "trange", + "trim_overlap", + "trima", + "trix_indicator", + "tsf", + "typprice", + "ultosc", + "var", + "vol_cone", + "volume_bars", + "vwap", + "vwma", + "walk_forward_indices", + "wclprice", + "weighted_continuous", + "willr", + "wma", + "yang_zhang_vol", + "zscore_series" + ] + } + }, + "parity_summary": { + "python_indicator_count": 210, + "wasm_export_count": 222, + "common_python_wasm_count": 92, + "common_python_wasm": [ + "ad", + "adosc", + "adx", + "adxr", + "apo", + "aroon", + "aroonosc", + "atr", + "avgprice", + "batch_ema", + "batch_rsi", + "batch_sma", + "bbands", + "bop", + "cci", + "chandelier_exit", + "check_cross", + "check_threshold", + "choppiness_index", + "cmo", + "collect_alert_bars", + "continuous_bar_labels", + "correl", + "correlation_matrix", + "dema", + "detect_breaks_cusum", + "donchian", + "dtw_distance", + "dx", + "ema", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendline", + "ht_trendmode", + "hull_ma", + "ichimoku", + "kama", + "keltner_channels", + "linearreg", + "linearreg_angle", + "linearreg_intercept", + "linearreg_slope", + "ma", + "macd", + "macdfix", + "mama", + "mavp", + "medprice", + "mfi", + "midpoint", + "midprice", + "minus_di", + "minus_dm", + "mom", + "natr", + "obv", + "pivot_points", + "plus_di", + "plus_dm", + "portfolio_volatility", + "ppo", + "regime_adx", + "regime_combined", + "roc", + "rocp", + "rocr", + "rocr100", + "rolling_variance_break", + "rsi", + "sar", + "sma", + "stddev", + "stoch", + "stochf", + "stochrsi", + "supertrend", + "t3", + "tema", + "trange", + "trima", + "tsf", + "typprice", + "ultosc", + "var", + "volume_bars", + "vwap", + "vwma", + "wclprice", + "willr", + "wma" + ], + "python_only_vs_wasm": [ + "acos", + "add", + "aggregate_ticks", + "alertevent", + "alertmanager", + "asin", + "atan", + "batch_apply", + "batch_dtw", + "beta", + "cdl2crows", + "cdl3blackcrows", + "cdl3inside", + "cdl3linestrike", + "cdl3outside", + "cdl3starsinsouth", + "cdl3whitesoldiers", + "cdlabandonedbaby", + "cdladvanceblock", + "cdlbelthold", + "cdlbreakaway", + "cdlclosingmarubozu", + "cdlconcealbabyswall", + "cdlcounterattack", + "cdldarkcloudcover", + "cdldoji", + "cdldojistar", + "cdldragonflydoji", + "cdlengulfing", + "cdleveningdojistar", + "cdleveningstar", + "cdlgapsidesidewhite", + "cdlgravestonedoji", + "cdlhammer", + "cdlhangingman", + "cdlharami", + "cdlharamicross", + "cdlhighwave", + "cdlhikkake", + "cdlhikkakemod", + "cdlhomingpigeon", + "cdlidentical3crows", + "cdlinneck", + "cdlinvertedhammer", + "cdlkicking", + "cdlkickingbylength", + "cdlladderbottom", + "cdllongleggeddoji", + "cdllongline", + "cdlmarubozu", + "cdlmatchinglow", + "cdlmathold", + "cdlmorningdojistar", + "cdlmorningstar", + "cdlonneck", + "cdlpiercing", + "cdlrickshawman", + "cdlrisefall3methods", + "cdlseparatinglines", + "cdlshootingstar", + "cdlshortline", + "cdlspinningtop", + "cdlstalledpattern", + "cdlsticksandwich", + "cdltakuri", + "cdltasukigap", + "cdlthrusting", + "cdltristar", + "cdlunique3river", + "cdlupsidegap2crows", + "cdlxsidegap3methods", + "ceil", + "compose", + "compute_many", + "cos", + "cosh", + "div", + "drawdown", + "dtw", + "exp", + "feature_matrix", + "floor", + "funding_pnl", + "ln", + "log10", + "macdext", + "max", + "maxindex", + "min", + "minindex", + "mult", + "multi_timeframe", + "rank_signals", + "regime", + "resample", + "resample_continuous", + "sarext", + "screen", + "session_boundaries", + "sin", + "sinh", + "sqrt", + "streamingatr", + "streamingbbands", + "streamingema", + "streamingmacd", + "streamingrsi", + "streamingsma", + "streamingstoch", + "streamingsupertrend", + "streamingvwap", + "structural_breaks", + "sub", + "sum", + "tan", + "tanh", + "tickaggregator", + "trix" + ], + "wasm_only_vs_python": [ + "adx_all", + "aggregate_greeks_dense", + "aggregate_tick_bars", + "aggregate_time_bars", + "aggregate_volume_bars_ticks", + "american_price", + "annualized_basis", + "atm_index", + "atm_iv", + "back_adjusted_continuous", + "backtest_core", + "batch_adx", + "batch_atr", + "batch_stoch", + "beta_full", + "beta_rolling", + "black_76_greeks", + "black_76_price", + "black_scholes_greeks", + "black_scholes_price", + "bottom_n_indices", + "calendar_spreads", + "carry_spread", + "close_to_close_vol", + "compose_rank", + "compose_weighted", + "compute_performance_metrics", + "curve_slope", + "curve_summary", + "digital_greeks", + "digital_price", + "drawdown_series", + "early_exercise_premium", + "exchange_charges_rate", + "expected_move", + "extended_greeks", + "extract_trades", + "fast_period", + "flat_per_order", + "forward_fill_nan", + "funding_cumulative_pnl", + "futures_basis", + "garman_klass_vol", + "gst_rate", + "half_kelly_fraction", + "implied_carry_rate", + "implied_volatility", + "iv_percentile", + "iv_rank", + "iv_zscore", + "kelly_fraction", + "label_moneyness", + "linear_interpolate", + "lot_size", + "macd_crossover_signals", + "make_chunk_ranges", + "mark_session_boundaries", + "math_add", + "math_div", + "math_mult", + "math_sub", + "max_brokerage", + "model_greeks", + "model_price", + "model_theta", + "monte_carlo_bootstrap", + "monthly_contribution", + "new", + "ohlcv_agg", + "parity_gap", + "parkinson_vol", + "per_lot", + "period", + "price_lower_bound", + "price_upper_bound", + "put_call_parity_deviation", + "rank_series", + "rank_values", + "rate_of_value", + "ratio", + "ratio_adjusted_continuous", + "regulatory_charges_rate", + "relative_strength", + "rogers_satchell_vol", + "roll_yield", + "rolling_beta", + "rolling_max", + "rolling_maxindex", + "rolling_min", + "rolling_minindex", + "rolling_sum", + "rsi_threshold_signals", + "select_strike_by_offset", + "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", + "signal_period", + "single_asset_backtest", + "slow_period", + "sma_crossover_signals", + "spread", + "stamp_duty_rate", + "stitch_chunks", + "strategy_payoff_dense", + "strategy_value_grid", + "stt_on_buy", + "stt_on_sell", + "stt_rate", + "synthetic_forward", + "synthetic_spot", + "term_structure_slope", + "top_n_indices", + "trade_stats", + "trim_overlap", + "trix_indicator", + "vol_cone", + "walk_forward_indices", + "weighted_continuous", + "yang_zhang_vol", + "zscore_series" + ] + } +} diff --git a/ferro-ta-main/docs/architecture.md b/ferro-ta-main/docs/architecture.md new file mode 100644 index 0000000..103e962 --- /dev/null +++ b/ferro-ta-main/docs/architecture.md @@ -0,0 +1,176 @@ +# Architecture + +This document describes the internal layout of **ferro-ta** — how the Rust and +Python layers are organised, how they communicate, and what each component is +responsible for. + +--- + +## Repository Layout + +``` +ferro-ta/ +├── src/ # Root PyO3 crate (Python extension, _ferro_ta) +│ ├── lib.rs # Module registration — assembles all sub-modules +│ ├── overlap/ # SMA, EMA, WMA, DEMA, TEMA, KAMA, BBANDS, … +│ ├── momentum/ # RSI, STOCH, ADX, CCI, AROON, WILLR, MFI, … +│ ├── volatility/ # ATR, NATR, TRANGE +│ ├── volume/ # AD, ADOSC, OBV +│ ├── statistic/ # STDDEV, VAR, LINEARREG, BETA, CORREL, … +│ ├── price_transform/ # AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE +│ ├── pattern/ # 61 CDL candlestick patterns +│ ├── cycle/ # HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, … +│ └── common.rs # Shared helpers (Wilder smoothing, etc.) +│ +├── crates/ +│ └── ferro_ta_core/ # Pure-Rust library (no PyO3 / numpy) +│ └── src/ # Used by fuzz targets and WASM binding +│ +├── python/ +│ └── ferro_ta/ # Python package +│ ├── __init__.py # Public API — re-exports + pandas/polars wraps +│ ├── _utils.py # _to_f64, pandas_wrap, polars_wrap, get_ohlcv +│ ├── overlap.py # Thin wrappers around _ferro_ta overlap functions +│ ├── momentum.py # … momentum +│ ├── volatility.py # … volatility +│ ├── volume.py # … volume +│ ├── statistic.py # … statistic +│ ├── price_transform.py # … price_transform +│ ├── pattern.py # … pattern (61 CDL functions) +│ ├── cycle.py # … cycle +│ ├── math_ops.py # ADD, SUB, MULT, DIV, SUM, MAX, MIN, math transforms +│ ├── extended.py # Extended indicators (VWAP, SUPERTREND, ICHIMOKU, …) +│ ├── streaming.py # Stateful streaming classes (StreamingSMA, …) +│ ├── batch.py # Batch execution API (batch_sma, batch_ema, …) +│ ├── pipeline.py # Pipeline / make_pipeline +│ ├── config.py # set_default / Config +│ ├── registry.py # Indicator registry (list_indicators, run) +│ ├── backtest.py # Simple backtest helpers +│ ├── gpu.py # CuPy-backed GPU PoC (SMA, EMA, RSI) +│ ├── exceptions.py # FerroTAError, FerroTAValueError, FerroTAInputError +│ ├── utils.py # Public re-export of get_ohlcv +│ └── py.typed # PEP 561 marker +│ +├── fuzz/ # cargo-fuzz targets (fuzz_sma, fuzz_rsi, …) +├── wasm/ # wasm-pack / wasm-bindgen binding (uses ferro_ta_core) +├── benches/ # Rust criterion benchmarks +├── benchmarks/ # Python pytest-benchmark benchmarks +├── docs/ # Sphinx documentation source +└── tests/ # Python pytest test suite +``` + +--- + +## Two Rust Crates + +ferro-ta has **two** Rust crates that serve different purposes: + +### 1. Root crate (`src/`) — Python extension (`_ferro_ta`) + +| Property | Value | +|----------------|---------------------------------------------------| +| Crate type | `cdylib` (compiled to a `.so` / `.pyd` file) | +| PyO3 / numpy | Yes — depends on `pyo3` and `numpy` | +| Depends on | `ta` crate (provides TA-Lib-compatible algorithms)| +| Used by | Python extension (`ferro_ta._ferro_ta`) | + +Each category module (`src/overlap/`, `src/momentum/`, …) registers +`#[pyfunction]`s that accept `numpy` arrays (via `PyReadonlyArray1`) +and return `Vec` which PyO3 converts to a Python list/ndarray. + +### 2. `crates/ferro_ta_core/` — Pure Rust library + +| Property | Value | +|----------------|-------------------------------------------------------------------| +| Crate type | `lib` (not a Python extension) | +| PyO3 / numpy | No — pure Rust, no Python dependency | +| Depends on | Nothing outside `std` | +| Used by | `fuzz/` targets and `wasm/` binding | + +`ferro_ta_core` provides the same indicator categories with a `&[f64]` API, +making it usable from WASM and fuzz targets without pulling in PyO3 or numpy. + +> **Note:** The root crate and `ferro_ta_core` are *independent* implementations. +> They are not merged by design — merging them would require careful testing of +> both the Python and WASM/fuzz surfaces. If you want to share code, the +> recommended path is to make the root crate depend on `ferro_ta_core` and wrap +> its `&[f64]` API with PyO3 `#[pyfunction]`s; that is a future refactor. + +--- + +## Python Binding Flow + +``` +User code + │ + ├── from ferro_ta import SMA # __init__.py re-export + │ │ + │ └── python/ferro_ta/overlap.py::SMA + │ │ + │ ├── _utils._to_f64(close) # convert to float64 ndarray + │ ├── check_timeperiod(n) # validate parameters + │ └── _ferro_ta.sma(arr, n) # call Rust extension + │ │ + │ └── src/overlap/sma.rs # pure Rust computation + │ + ├── SMA(pd.Series(...)) # pandas_wrap intercepts first + │ │ + │ ├── extracts .to_numpy(dtype=float64) + │ ├── calls SMA(ndarray) + │ └── wraps result in pd.Series(result, index=original_index) + │ + └── SMA(pl.Series(...)) # polars_wrap intercepts first + │ + ├── extracts .cast(Float64).to_numpy() + ├── calls SMA(ndarray) + └── wraps result in pl.Series(name, np.asarray(result)) +``` + +Both `pandas_wrap` and `polars_wrap` are applied to every public name in +`__init__.py` so the same function transparently handles numpy arrays, +pandas Series, and polars Series. + +--- + +## Extended Indicators, Streaming, and Batch + +| Module | Implementation | Notes | +|---------------|-----------------------------|-------------------------------------------------------------| +| `extended.py` | Rust (`src/extended/`) | VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, … | +| `streaming.py`| Rust re-export | Stateful classes (StreamingSMA, StreamingEMA, …) from `_ferro_ta`; no Python fallback | +| `batch.py` | Rust for 2-D SMA/EMA/RSI | `batch_sma`, `batch_ema`, `batch_rsi` call Rust batch functions; `batch_apply` is a Python loop for other indicators | + +Streaming and batch 2-D paths are implemented in Rust for maximum performance. +The generic `batch_apply` remains for indicators that do not have a dedicated +Rust batch implementation (see `docs/performance.md`). + +--- + +## Packaging and Build + +- **Build backend:** [maturin](https://www.maturin.rs/) — compiles the root + crate and packages it alongside the Python source into a wheel. +- **`python-source = "python"`** in `pyproject.toml` tells maturin where the + Python package lives. +- **`module-name = "ferro_ta._ferro_ta"`** tells maturin to place the compiled + `.so` at `ferro_ta/_ferro_ta.so` inside the wheel. +- Wheels are built for Linux (manylinux), Windows, and macOS via CI on release. + +--- + +## Where Validation Lives + +Currently most validation (array length checks, `timeperiod` range checks) is +done in Python wrappers before the Rust call. A future improvement is to move +these checks into the `#[pyfunction]`s so that callers using the raw +`_ferro_ta` extension directly also get clear errors. + +--- + +## Related Documents + +- [`docs/performance.md`](performance.md) — when to use raw numpy vs pandas/polars, + how to avoid unnecessary conversion, batch performance notes. +- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — development workflow, running tests, + adding a new indicator. +- [`CHANGELOG.md`](../CHANGELOG.md) — version history. diff --git a/ferro-ta-main/docs/batch.rst b/ferro-ta-main/docs/batch.rst new file mode 100644 index 0000000..cf155f8 --- /dev/null +++ b/ferro-ta-main/docs/batch.rst @@ -0,0 +1,42 @@ +Batch Execution API +=================== + +The batch API lets you run indicators on multiple price series in a single +call. This reduces Python overhead compared to calling the 1-D function in a +loop and naturally maps to multi-asset / multi-symbol workflows. + +All batch functions accept a 2-D array of shape ``(n_samples, n_series)`` and +return a 2-D array of the same shape. Passing a 1-D array falls back to the +single-series behaviour. + +Usage +----- + +.. code-block:: python + + import numpy as np + from ferro_ta.batch import batch_sma, batch_ema, batch_rsi, batch_apply + + # 100 bars, 5 symbols + close = np.random.rand(100, 5) + 50.0 + + sma = batch_sma(close, timeperiod=14) # shape (100, 5) + ema = batch_ema(close, timeperiod=14) # shape (100, 5) + rsi = batch_rsi(close, timeperiod=14) # shape (100, 5) + + # Apply any indicator using batch_apply + from ferro_ta import MACD + # MACD returns a tuple so we wrap it + def macd_line(c, **kw): + return MACD(c, **kw)[0] + + macd = batch_apply(close, macd_line) # shape (100, 5) + +API Reference +------------- + +.. automodule:: ferro_ta.batch + :no-index: + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/benchmarks.rst b/ferro-ta-main/docs/benchmarks.rst new file mode 100644 index 0000000..7b5a3d3 --- /dev/null +++ b/ferro-ta-main/docs/benchmarks.rst @@ -0,0 +1,246 @@ +Benchmarks +========== + +The benchmark suite is meant to support a narrow claim: ferro-ta is often +faster on selected indicators, and the evidence is published in a reproducible +form. + +What is published +----------------- + +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 +--------------------------------- + +The current checked-in TA-Lib comparison artifact benchmarks contiguous +``float64`` arrays at 10k and 100k bars on an ``Apple M3 Max`` with 14 logical +cores, about 38.7 GB RAM, ``CPython 3.13.5``, and ``Rust 1.91.1`` using the +default release profile (``lto = true``, ``codegen-units = 1``). + +Summary from ``benchmarks/artifacts/latest/benchmark_vs_talib.json``: + +.. list-table:: + :header-rows: 1 + + * - Size + - Rows + - ferro-ta wins + - Median speedup + - TA-Lib wins or ties + * - ``10,000`` + - 12 + - 6 + - ``1.0850x`` + - ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV`` + * - ``100,000`` + - 12 + - 6 + - ``1.0784x`` + - ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV`` + +Examples from the 100k-bar run: + +.. list-table:: + :header-rows: 1 + + * - Indicator + - ferro-ta + - TA-Lib + - Speedup + - Read + * - ``SMA`` + - ``0.0985 ms`` + - ``0.2241 ms`` + - ``2.2751x`` + - clear ferro-ta win + * - ``BBANDS`` + - ``0.2122 ms`` + - ``0.4966 ms`` + - ``2.3402x`` + - clear ferro-ta win + * - ``MACD`` + - ``0.5152 ms`` + - ``0.7111 ms`` + - ``1.3801x`` + - ferro-ta win + * - ``STOCH`` + - ``1.7064 ms`` + - ``0.7603 ms`` + - ``0.4455x`` + - TA-Lib win + * - ``ADX`` + - ``0.7910 ms`` + - ``0.5769 ms`` + - ``0.7294x`` + - TA-Lib win + * - ``ATR`` + - ``0.5087 ms`` + - ``0.5147 ms`` + - ``1.0118x`` + - tie on this machine + +Methodology notes +----------------- + +- The head-to-head script uses the same synthetic OHLCV generator, the same + parameters, and the same contiguous ``float64`` array layout for both + libraries. +- Reported speedup is ``TA-Lib median time / ferro-ta median time``. +- The script uses 1 warmup run and 7 measured runs per case, and now records + the full per-run timing samples, not just one selected number. +- Published JSON artifacts include machine/runtime metadata, git metadata, Rust + toolchain and build-profile metadata, per-run variance statistics, and + Python-tracked peak allocation snapshots. +- Allocation snapshots are based on ``tracemalloc`` and capture Python-tracked + allocations only; they are not full native RSS profiles. +- If your workload uses non-contiguous arrays, different dtypes, or different + batch sizes, benchmark that exact workload. Those factors can materially + change the result. + +Reproduce the TA-Lib comparison +------------------------------- + +.. code-block:: bash + + pip install ta-lib + python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json + +The JSON output is the main artifact to review when publishing performance +claims. + +Cross-library suite +------------------- + +Run the broader speed suite on 100,000 bars: + +.. code-block:: bash + + uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v + +Selected throughput examples from the checked-in table: + +.. list-table:: + :header-rows: 1 + + * - Indicator + - Throughput + * - ``ADD`` + - 1.9 G bars/s + * - ``CDLENGULFING`` + - 454 M bars/s + * - ``EMA`` + - 444 M bars/s + * - ``SMA`` + - 259 M bars/s + * - ``RSI`` + - 145 M bars/s + * - ``ATR`` + - 70 M bars/s + * - ``MACD`` + - 104 M bars/s + * - ``STOCH`` + - 33 M bars/s + +Perf-contract artifacts +----------------------- + +Use the perf-contract runner when you want a compact, machine-readable artifact +bundle for single-series latency, batch throughput, streaming throughput, and +hotspot attribution: + +.. code-block:: bash + + uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest + +See ``benchmarks/README.md`` for the detailed benchmark playbook and the +checked-in comparison tables. diff --git a/ferro-ta-main/docs/changelog.rst b/ferro-ta-main/docs/changelog.rst new file mode 100644 index 0000000..27e4d36 --- /dev/null +++ b/ferro-ta-main/docs/changelog.rst @@ -0,0 +1,271 @@ +Release Notes +============= + +These docs track package version ``1.2.0``. + +1.1.0-audit (2026-03-28) +------------------------ + +**Comprehensive audit: 90 findings addressed** + +*Code quality & correctness* + +- **Welford's algorithm for BBANDS**: replaced naive ``sum_sq/N - mean^2`` variance + with numerically stable Welford's rolling algorithm in both batch and streaming BBANDS. + Fixes catastrophic cancellation for large-valued series (e.g., prices near 1e12). +- **FFI boundary safety**: ``transpose_to_series_major()`` in ``batch/mod.rs`` now + returns ``PyResult`` instead of using ``expect()``. Remaining ``as_slice().expect()`` + calls in ``allow_threads`` closures are documented with SAFETY comments (structurally + infallible after C-contiguous transpose). +- **Clippy clean**: resolved all clippy warnings — complex type in ``adx_all`` extracted + to ``AdxAllResult`` type alias; ``welford_step`` helper annotated with + ``#[allow(clippy::too_many_arguments)]``. + +*Performance* + +- **``target-cpu=native``**: new ``.cargo/config.toml`` enables native CPU instruction + set (AVX2, NEON, etc.) for all non-WASM targets. CI can override via ``RUSTFLAGS``. + +*Testing* + +- **Streaming unit tests**: 37 new tests in ``tests/unit/streaming/test_streaming.py`` + covering ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI`` — batch parity, warmup + NaN behavior, reset, edge cases, and large dataset numerical stability. +- **Edge case tests**: 31 new tests in ``tests/unit/test_edge_cases.py`` — empty arrays, + single elements, all-NaN input, NaN propagation, extreme values (1e300, 1e-300), + constant series, period boundary conditions, OHLCV edge cases, and dtype coercion + (float32, int64). +- **Property-based tests**: expanded Hypothesis tests for EMA, BBANDS, MACD, ATR, WMA, + and OBV with algebraic invariants (upper >= middle >= lower, histogram == macd - signal, + ATR non-negative, etc.). +- **Pandas/polars integration tests**: new ``test_dataframe_integration.py`` verifying + transparent ``pd.Series`` and ``polars.Series`` support across SMA, EMA, RSI, BBANDS, + MACD, and end-to-end DataFrame workflows. +- **Fuzzing**: expanded from 2 to 9 fuzz targets — added EMA, BBANDS, MACD, ATR, STOCH, + MFI, and WMA with output invariant assertions. +- **Test helpers**: new ``tests/unit/helpers.py`` consolidating duplicated assertion + patterns (``nan_count``, ``finite``, ``assert_nan_warmup``, ``assert_output_length``, + ``assert_range``, ``make_ohlcv``). + +*Documentation* + +- **README benchmarks**: updated to match actual artifact data — MFI 3.25x, WMA 2.20x, + BBANDS 1.97x, SMA 1.93x; corrected win count from 6 to 7 at 100k bars. +- **Rust doc comments**: added comprehensive ``///`` documentation to all public functions + in ``ferro_ta_core`` — overlap (SMA, EMA, WMA, BBANDS, MACD), momentum (RSI, STOCH, + ADX family), volatility (ATR, TRANGE), volume (OBV, MFI), statistic (STDDEV), and math + (sum, max, min, sliding_max, sliding_min). + +*Linting* + +- **Ruff clean**: fixed import sorting, unused imports, trailing whitespace, and + formatting across all Python files. +- **cargo fmt**: all Rust code formatted. + +1.1.0 (2026-03-28) +------------------ + +**Phase 1 — Simulation fidelity** + +- **Bid-ask spread model**: new ``CommissionModel.spread_bps`` field (basis points). + Half-spread is deducted per leg (entry and exit), modelling real market microstructure costs. +- **Breakeven stop**: new ``backtest_ohlcv_core`` parameter ``breakeven_pct`` and + ``BacktestEngine.with_breakeven_stop(pct)``. Once profit reaches ``pct``, the + effective stop-loss is moved to the entry price, guaranteeing at worst a breakeven exit. +- **Bracket order priority**: when both stop-loss and take-profit are breached on the + same bar, the level closer to the bar's open price fires first (previously SL always won). + +**Phase 2 — Portfolio & risk** + +- **Short borrow cost**: new ``CommissionModel.short_borrow_rate_annual`` field. + Accrued per bar for short positions at the specified annualised rate. +- **Leverage / margin modeling**: new ``BacktestEngine.with_leverage(margin_ratio, margin_call_pct)``. + Tracks margin usage and triggers a margin-call force-close when equity falls below + ``margin_call_pct × initial_margin``. +- **Loss circuit breakers**: new ``BacktestEngine.with_loss_limits(daily, total)``. + Halts all trading when a per-bar loss or total drawdown threshold is breached. +- **Portfolio constraints**: new ``BacktestEngine.with_portfolio_constraints(max_asset_weight, + max_gross_exposure, max_net_exposure)`` for multi-asset backtests. + +**Phase 3 — Data & UX** + +- **Bar aggregation** (``ferro_ta.analysis.resample``): ``resample_ohlcv()``, ``align_to_coarse()``, + ``resample_ohlcv_labels()`` — pure-NumPy OHLCV resampling from any fine TF to any coarser TF. +- **Multi-timeframe engine** (``ferro_ta.analysis.multitf``): ``MultiTimeframeEngine`` — compute + strategy signals on coarser bars and execute on finer bars, with automatic signal alignment. +- **Dividend/split adjustment** (``ferro_ta.analysis.adjust``): ``adjust_ohlcv()``, + ``adjust_for_splits()``, ``adjust_for_dividends()`` — backward-adjusted price series for + equity/index strategies. +- **Visualization** (``ferro_ta.analysis.plot``): ``plot_backtest()`` — interactive Plotly chart + with equity curve, drawdown panel, position panel, trade markers, and optional benchmark overlay. + +**Phase 4 — Differentiation** + +- **Regime detection** (``ferro_ta.analysis.regime``): ``detect_volatility_regime()``, + ``detect_trend_regime()``, ``detect_combined_regime()``, ``RegimeFilter`` — pure-NumPy + 6-state market regime labeling and signal filtering; no external ML dependencies. +- **Portfolio optimization** (``ferro_ta.analysis.optimize``): ``PortfolioOptimizer``, + ``mean_variance_optimize()``, ``risk_parity_optimize()``, ``max_sharpe_optimize()`` — + minimum-variance, risk-parity, and maximum-Sharpe portfolios via SLSQP (requires scipy). +- **Paper trading bridge** (``ferro_ta.analysis.live``): ``PaperTrader`` — event-driven + bar-by-bar simulator matching ``backtest_ohlcv_core`` logic exactly; supports streaming + data, live state inspection, and seamless strategy migration from backtesting to live. + +1.1.0 (2026-03-27) +------------------ + +**Advanced commission and fee model (Indian market support)** + +- New ``CommissionModel`` class (pure Rust in ``ferro_ta_core``, exposed via + PyO3 and WASM) replaces the broken flat ``commission_per_trade`` scalar. The + old code subtracted an absolute currency amount from a 1.0-normalised equity + curve — equivalent to a 2 000 % error on a ₹1 lakh account. The new model + correctly converts every charge to a fraction of ``initial_capital`` before + deducting it from the equity curve. +- ``CommissionModel`` supports: proportional brokerage (``rate_of_value``), + flat per-order fee (``flat_per_order``), per-lot fee (``per_lot``), brokerage + cap (``max_brokerage``), Securities Transaction Tax (``stt_rate`` with + configurable buy/sell sides), exchange transaction charges, SEBI regulatory + charges, 18 % GST on brokerage + exchange + regulatory levies, and stamp duty + on buy leg only. +- Built-in presets: ``CommissionModel.equity_delivery_india()``, + ``CommissionModel.equity_intraday_india()``, + ``CommissionModel.futures_india()``, ``CommissionModel.options_india()``, + ``CommissionModel.proportional(rate)``, ``CommissionModel.zero()``. +- JSON persistence: ``model.to_json()`` / ``CommissionModel.from_json(s)``, + ``model.save(path)`` / ``CommissionModel.load(path)``. +- ``BacktestEngine.with_commission_model(model)`` — pass a full + ``CommissionModel``; old ``with_commission(rate)`` kept as a shim. +- New ``initial_capital`` parameter (default ₹1,00,000) on both + ``backtest_core`` and ``backtest_ohlcv_core``; also exposed as + ``BacktestEngine.with_initial_capital(capital)``. + +**Currency system — INR default with lakh/crore formatting** + +- New ``Currency`` immutable descriptor in the Python layer with constants + ``INR``, ``USD``, ``EUR``, ``GBP``, ``JPY``, ``USDT``. +- ``INR`` is the default currency for ``BacktestEngine``; change via + ``engine.with_currency("USD")`` or ``engine.with_currency(EUR)``. +- ``currency.format(amount)`` produces Indian lakh/crore grouping for INR + (e.g. ``₹1,23,45,678.00``) and standard Western grouping for other + currencies. +- Module-level helper ``format_currency(amount, currency=INR)``. +- ``AdvancedBacktestResult`` gains ``currency``, ``initial_capital``, and + ``equity_abs`` (absolute currency equity curve) slots. +- ``summary()`` now includes ``initial_capital``, ``final_capital``, + ``absolute_pnl``, and ``currency`` keys. +- ``AdvancedBacktestResult.__repr__`` shows the final capital in the correct + currency symbol (e.g. ``final=₹1,23,450.00``). +- Trade log gains a ``pnl_abs`` column (PnL in absolute currency units). +- ``to_equity_dataframe()`` now includes an ``equity_abs`` column. + +**Trailing stop loss** + +- ``backtest_ohlcv_core`` (and ``BacktestEngine.with_trailing_stop(pct)``) + now supports a trailing stop implemented intrabar in Rust: the high-water + mark is updated each bar; the position is exited at + ``trail_high × (1 − pct)`` when ``low[i]`` crosses below it (long trades), + or ``trail_low × (1 + pct)`` for short trades. + +**Benchmark comparison metrics** + +- ``compute_performance_metrics`` accepts an optional ``benchmark_returns`` + array. When provided, ``summary()`` includes: ``benchmark_total_return``, + ``benchmark_cagr``, ``benchmark_annualized_vol``, ``benchmark_sharpe``, + ``alpha`` (active return), ``beta``, ``tracking_error``, and + ``information_ratio``. +- ``BacktestEngine.with_benchmark(close_array)`` — pass benchmark close prices. + +**Volatility-target position sizing** + +- New ``"volatility_target"`` method for ``with_position_sizing()``: + ``engine.with_position_sizing("volatility_target", target_vol=0.15, vol_window=20)``. + Signals are pre-scaled in Python by ``clip(target_vol / rolling_annualised_vol, 0, 3)`` + before the Rust core call, keeping the hot loop unchanged. + +**Backtesting engine v2 — full feature set** + +- ``BacktestEngine`` now supports true two-pass Kelly / half-Kelly position + sizing: a unit-signal pass computes win statistics, then the core engine + re-runs with signals scaled by the Kelly fraction. +- Added ``fixed_fractional`` position sizing method: + ``engine.with_position_sizing("fixed_fractional", fraction=0.5)``. +- New ``StreamingBacktest`` Rust class for bar-by-bar incremental backtesting + (no bulk arrays needed); exposes ``.on_bar()``, ``.summary()``, ``.reset()``. +- ``AdvancedBacktestResult.to_equity_dataframe(freq)`` — returns equity, + returns, and drawdown as a ``pd.DataFrame`` with a synthetic DatetimeIndex. +- ``AdvancedBacktestResult.summary()`` — concise dict of the 9 most commonly + cited metrics plus ``n_trades``. + +**Core indicator speedup** + +- ADX-family indicators (``adx_all`` public API): all six series (PDM, MDM, + +DI, -DI, DX, ADX) can now be computed from a single TR/PDM/MDM pass via + ``ferro_ta.adx_all()``, eliminating the 6× redundant computation that + occurred when callers fetched each series independently. +- ``adxr`` now reuses a single ``adx_inner`` call internally (was calling + ``adx()`` which re-ran the inner loop). + +1.0.6 (2026-03-24) +------------------ + +- Added a repo-managed pre-push gate so the core Rust, Python, docs, and WASM + checks can be run locally before release. +- Expanded Rust-backed analysis/data helpers, broadened the WASM exports, and + added cross-surface API manifest verification plus Node conformance checks. +- Refreshed benchmark coverage and perf artifacts, aligned Python CI with the + local tooling flow, and updated the locked security fixes needed for a clean + release pass. + +1.0.4 (2026-03-24) +------------------ + +- Expanded the optional MCP server from a small hand-written subset to the + broader public ferro-ta callable surface, including stateful class support + through stored-instance management tools. +- Split the root documentation so the full TA-Lib compatibility matrix lives in + ``TA_LIB_COMPATIBILITY.md`` while the README stays product-first and shorter. +- Refreshed MCP docs/tests and updated locked low-risk Python dependencies as + part of the release cleanup pass. +- Stopped tracking the stray ``.coverage`` artifact and aligned ignore rules + for local coverage outputs. + +1.0.3 (2026-03-24) +------------------ + +- Added top-level package metadata helpers such as ``ferro_ta.__version__``, + ``ferro_ta.about()``, and ``ferro_ta.methods()``. +- Added a standalone derivatives benchmark artifact for selected options + pricing, IV, Greeks, and Black-76 comparisons. +- Simplified release version bumps with a single script and updated release + guidance. +- Fixed Python CI/type-stub gaps around the new metadata API and corrected the + tag-driven GitHub Release workflow trigger used for publish automation. + +1.0.2 (2026-03-24) +------------------ + +- Improved rolling statistical kernels and several Python analysis hotspots. +- Added reproducible perf-contract artifacts, TA-Lib regression guards, and + updated benchmark tooling. +- Tightened the public benchmark documentation so claims, caveats, and evidence + live closer together. + +1.0.1 (2026-03-24) +------------------ + +- Improved release automation for PyPI, crates.io, and npm. +- Fixed CI workflow issues that caused otherwise healthy release jobs to fail. +- Ensured the published WASM package includes its built ``pkg/`` artifacts. + +1.0.0 (2026-03-23) +------------------ + +- First stable release of the Rust-backed Python technical analysis library. +- Shipped broad TA-Lib coverage, streaming APIs, extended indicators, and the + initial Sphinx documentation set. +- Added the benchmark suite, release playbook, and compatibility/testing + scaffolding for stable releases. + +For the canonical project changelog, including the full per-version details, +see `CHANGELOG.md `_. diff --git a/ferro-ta-main/docs/compatibility/finta.md b/ferro-ta-main/docs/compatibility/finta.md new file mode 100644 index 0000000..741eaa2 --- /dev/null +++ b/ferro-ta-main/docs/compatibility/finta.md @@ -0,0 +1,145 @@ +# ferro-ta ↔ finta Compatibility + +[finta](https://github.com/peerchemist/finta) implements over 80 financial +technical indicators as class methods on a single `TA` class, operating +entirely on Pandas DataFrames. + +--- + +## Key architectural differences + +| Aspect | ferro-ta | finta | +|--------|---------|-------| +| **Backend** | Rust/C + SIMD | Pure Pandas | +| **Input type** | NumPy array or list | OHLCV Pandas DataFrame (required) | +| **DatetimeIndex** | Not required | **Required** | +| **Column names** | Separate arrays | `open/high/low/close/volume` | +| **Output type** | NumPy array | Pandas Series or DataFrame | +| **NaN handling** | Pads warmup with NaN | Pads warmup with NaN | +| **Streaming** | Yes (StreamingXxx classes) | No | +| **Speed** | ~700× faster on ATR | Baseline (pure Pandas) | + +--- + +## Required DataFrame format + +finta requires a **Pandas DataFrame with a DatetimeIndex** and lowercase +column names: + +```python +import pandas as pd +import numpy as np + +df = pd.DataFrame({ + "open": open_prices, + "high": high_prices, + "low": low_prices, + "close": close_prices, + "volume": volume_data, # required for volume indicators +}, index=pd.date_range("2020-01-01", periods=len(close_prices), freq="D")) +``` + +ferro-ta accepts raw NumPy arrays or Python lists — no DataFrame needed. + +--- + +## Function signature mapping + +finta uses a class-method API: `TA.INDICATOR(ohlcv_df, period, ...)`. + +| Indicator | ferro-ta | finta | +|-----------|---------|-------| +| SMA | `SMA(close, timeperiod=20)` | `TA.SMA(df, 20)` | +| EMA | `EMA(close, timeperiod=20)` | `TA.EMA(df, 20)` | +| WMA | `WMA(close, timeperiod=14)` | `TA.WMA(df, 14)` | +| DEMA | `DEMA(close, timeperiod=30)` | `TA.DEMA(df, 30)` | +| TEMA | `TEMA(close, timeperiod=30)` | `TA.TEMA(df, 30)` | +| HMA | Not supported | `TA.HMA(df, 16)` | +| RSI | `RSI(close, timeperiod=14)` | `TA.RSI(df, 14)` | +| MACD | `MACD(close, 12, 26, 9)` → (macd, signal, hist) | `TA.MACD(df, 12, 26, 9)` → DataFrame with `MACD`/`SIGNAL` columns | +| BBANDS | `BBANDS(close, 20, 2.0, 2.0)` → (upper, mid, lower) | `TA.BBANDS(df, 20)` → DataFrame with `BB_UPPER`/`BB_MIDDLE`/`BB_LOWER` | +| ATR | `ATR(high, low, close, timeperiod=14)` | `TA.ATR(df, 14)` | +| TRUE RANGE | `TRANGE(high, low, close)` | `TA.TR(df)` | +| OBV | `OBV(close, volume)` | `TA.OBV(df)` | +| MFI | `MFI(high, low, close, volume, timeperiod=14)` | `TA.MFI(df, 14)` | +| CCI | `CCI(high, low, close, timeperiod=14)` | `TA.CCI(df, 14)` | +| STOCH | `STOCH(high, low, close, 5, 3, 3)` | `TA.STOCH(df, 14)` | +| WILLR | `WILLR(high, low, close, timeperiod=14)` | `TA.WILLIAMS(df, 14)` | +| ADX | `ADX(high, low, close, timeperiod=14)` | `TA.ADX(df, 14)` | +| AROON | `AROON(high, low, timeperiod=14)` → (up, down) | `TA.AROON(df, 14)` → DataFrame | + +--- + +## Numerical accuracy + +finta uses sample standard deviation (ddof=1) for Bollinger Bands while +ferro-ta follows the TA-Lib convention (population std, ddof=0). For a +window of 20 bars this creates a ~0.5% difference in band width. + +For EMA-based indicators, finta seeds with the first data point while ferro-ta +follows TA-Lib (SMA of first `timeperiod` bars). Values converge after +~3× the period. + +Cross-library correlation between ferro-ta and finta is ≥ 0.95 for all +indicators after discarding the warm-up period. + +--- + +## Speed comparison + +On 10,000 bars (median µs, Apple M-series): + +| Indicator | ferro-ta | finta | ferro-ta speedup | +|-----------|--------:|-------:|----------------:| +| SMA | 16.7 | 178.1 | **10.7×** | +| MACD | 70.4 | 383.9 | **5.5×** | +| ATR | 51.4 | 1,247 | **24×** | + +On 100,000 bars: + +| Indicator | ferro-ta | finta | ferro-ta speedup | +|-----------|--------:|--------:|----------------:| +| SMA | 126.2 | 699.7 | **5.6×** | +| MACD | 465.9 | 1,470.8 | **3.2×** | +| ATR | 478.5 | 6,782 | **14×** | + +finta's ATR scales especially poorly because it relies on Pandas `.apply()` +with a lambda, which cannot be vectorised. + +--- + +## Migration guide + +```python +# FROM finta +import pandas as pd +from finta import TA + +ohlcv = pd.DataFrame(...) # must have DatetimeIndex + open/high/low/close/volume +sma = TA.SMA(ohlcv, 20) # returns Pandas Series +macd_df = TA.MACD(ohlcv, 12, 26, 9) # returns DataFrame with MACD/SIGNAL cols +bb_df = TA.BBANDS(ohlcv, 20) # returns DataFrame with BB_UPPER/MIDDLE/LOWER + +# TO ferro-ta (NumPy arrays — no DataFrame required) +import ferro_ta +import numpy as np + +close = ohlcv["close"].values +sma = ferro_ta.SMA(close, timeperiod=20) + +macd, signal, hist = ferro_ta.MACD(close, fastperiod=12, slowperiod=26, signalperiod=9) + +upper, middle, lower = ferro_ta.BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0) +``` + +--- + +## Known limitations + +- finta cannot process raw NumPy arrays — a properly formatted DataFrame with + DatetimeIndex is always required. +- `TA.MACD` only returns `MACD` and `SIGNAL` columns; the histogram must be + computed manually as `MACD - SIGNAL`. +- Several finta indicators use non-standard formulas that may not match TA-Lib + conventions (e.g. STOCH uses a fixed 14-period window regardless of the + `fastk_period` argument). diff --git a/ferro-ta-main/docs/compatibility/pandas_ta.md b/ferro-ta-main/docs/compatibility/pandas_ta.md new file mode 100644 index 0000000..d475266 --- /dev/null +++ b/ferro-ta-main/docs/compatibility/pandas_ta.md @@ -0,0 +1,108 @@ +# Compatibility: ferro-ta vs pandas-ta + +ferro-ta provides indicators that match [pandas-ta](https://github.com/twopirllc/pandas-ta) +results to within numerical tolerance. This guide explains how to migrate from +pandas-ta and how to run the cross-library validation tests. + +## Installation + +```bash +pip install ferro-ta +# Optional: install pandas-ta to run comparison tests +pip install pandas-ta +``` + +## API Comparison + +### pandas-ta style (accessor) + +```python +import pandas as pd +import pandas_ta as ta + +close = pd.Series([...]) +sma = close.ta.sma(length=20) +ema = close.ta.ema(length=14) +rsi = close.ta.rsi(length=14) +``` + +### ferro-ta equivalent + +```python +import numpy as np +import ferro_ta as ft + +close = np.array([...]) +sma = ft.SMA(close, timeperiod=20) +ema = ft.EMA(close, timeperiod=14) +rsi = ft.RSI(close, timeperiod=14) +``` + +> **Note**: ferro-ta operates on NumPy arrays. If you have a `pd.Series`, pass +> it directly — ferro-ta will convert it automatically. + +## Indicator Mapping + +| pandas-ta | ferro-ta | Notes | +|---|---|---| +| `ta.sma(length=N)` | `ft.SMA(close, timeperiod=N)` | Exact match | +| `ta.ema(length=N)` | `ft.EMA(close, timeperiod=N)` | Tail convergence within 1e-6 | +| `ta.wma(length=N)` | `ft.WMA(close, timeperiod=N)` | Exact match | +| `ta.rsi(length=N)` | `ft.RSI(close, timeperiod=N)` | Tail convergence | +| `ta.macd(fast, slow, signal)` | `ft.MACD(close, fastperiod, slowperiod, signalperiod)` | Tail convergence | +| `ta.bbands(length=N, std=2)` | `ft.BBANDS(close, timeperiod=N, nbdevup=2, nbdevdn=2)` | Exact match | +| `ta.stoch(high, low, close)` | `ft.STOCH(high, low, close, ...)` | Tail convergence | +| `ta.cci(high, low, close, length=N)` | `ft.CCI(high, low, close, timeperiod=N)` | Exact match | +| `ta.mom(length=N)` | `ft.MOM(close, timeperiod=N)` | Exact match | +| `ta.roc(length=N)` | `ft.ROC(close, timeperiod=N)` | Exact match | +| `ta.trima(length=N)` | `ft.TRIMA(close, timeperiod=N)` | Exact match | +| `ta.hma(length=N)` | `ft.HT_MA(close, timeperiod=N)` | Hull MA variant | +| `ta.ichimoku(...)` | `ft.ICHIMOKU(high, low, close)` | Tenkan/Kijun match | +| `ta.kc(high, low, close, ...)` | `ft.KELTNER(high, low, close, ...)` | Tail convergence | + +## Batch Execution + +ferro-ta supports running many indicators at once via the batch API: + +```python +import numpy as np +import ferro_ta as ft + +data = np.random.randn(1000, 50) # 50 instruments × 1000 bars + +# Run SMA(20) across all 50 instruments in one call +results = ft.batch_compute(data, "SMA", timeperiod=20) +``` + +## Running the Cross-Library Tests + +Cross-library comparison tests live in `tests/integration/test_vs_pandas_ta.py`. +They are automatically **skipped** when pandas-ta is not installed. + +```bash +# Install pandas-ta first +pip install pandas-ta + +# Run comparison tests +pytest tests/integration/test_vs_pandas_ta.py -v +``` + +## Known Differences + +- **Seeding period**: EMA results during the first `timeperiod` bars may differ + due to different initialization strategies (SMA seed vs EMA seed). Results + converge after the seeding window. +- **MACD signal line**: The signal EMA is seeded from the first valid MACD value. + Exact match begins after 2× `slowperiod` bars. +- **STOCH smoothing**: ferro-ta defaults match TA-Lib (SMA slowk, SMA slowd). + pandas-ta uses different defaults; pass matching parameters explicitly. + +## Performance Comparison + +ferro-ta is 10–100× faster than pandas-ta for large arrays because the core +computation is written in Rust: + +```bash +# Run the benchmark +pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json +``` diff --git a/ferro-ta-main/docs/compatibility/ta.md b/ferro-ta-main/docs/compatibility/ta.md new file mode 100644 index 0000000..120fdf7 --- /dev/null +++ b/ferro-ta-main/docs/compatibility/ta.md @@ -0,0 +1,104 @@ +# Compatibility: ferro-ta vs ta (Bukosabino) + +ferro-ta provides indicators that match [ta](https://github.com/bukosabino/ta) +(Bukosabino's library) results to within numerical tolerance. This guide +explains how to migrate from `ta` and how to run the cross-library validation +tests. + +## Installation + +```bash +pip install ferro-ta +# Optional: install ta to run comparison tests +pip install ta +``` + +## API Comparison + +### ta style + +```python +import pandas as pd +from ta.momentum import RSIIndicator, StochasticOscillator +from ta.volatility import AverageTrueRange, BollingerBands +from ta.trend import SMAIndicator, EMAIndicator, MACD, CCIIndicator +from ta.volume import OnBalanceVolumeIndicator +from ta.others import DailyReturnIndicator + +close = pd.Series([...]) +high = pd.Series([...]) +low = pd.Series([...]) +volume = pd.Series([...]) + +rsi = RSIIndicator(close, window=14).rsi() +sma = SMAIndicator(close, window=20).sma_indicator() +ema = EMAIndicator(close, window=14).ema_indicator() +``` + +### ferro-ta equivalent + +```python +import numpy as np +import ferro_ta as ft + +close = np.array([...]) +high = np.array([...]) +low = np.array([...]) +volume = np.array([...]) + +rsi = ft.RSI(close, timeperiod=14) +sma = ft.SMA(close, timeperiod=20) +ema = ft.EMA(close, timeperiod=14) +``` + +> **Note**: ferro-ta operates on NumPy arrays. If you have a `pd.Series`, pass +> it directly — ferro-ta will convert it automatically. + +## Indicator Mapping + +| ta | ferro-ta | Notes | +|---|---|---| +| `SMAIndicator(close, window=N).sma_indicator()` | `ft.SMA(close, timeperiod=N)` | Exact match | +| `EMAIndicator(close, window=N).ema_indicator()` | `ft.EMA(close, timeperiod=N)` | Tail convergence | +| `BollingerBands(close, window=N, window_dev=2)` | `ft.BBANDS(close, timeperiod=N, nbdevup=2, nbdevdn=2)` | Exact match | +| `RSIIndicator(close, window=N).rsi()` | `ft.RSI(close, timeperiod=N)` | Tail convergence | +| `MACD(close, window_slow, window_fast, window_sign)` | `ft.MACD(close, fastperiod, slowperiod, signalperiod)` | Tail convergence | +| `StochasticOscillator(high, low, close, window, smooth_window)` | `ft.STOCH(high, low, close, ...)` | Tail convergence | +| `AverageTrueRange(high, low, close, window=N)` | `ft.ATR(high, low, close, timeperiod=N)` | Tail convergence | +| `WilliamsRIndicator(high, low, close, lbp=N)` | `ft.WILLR(high, low, close, timeperiod=N)` | Exact match | +| `OnBalanceVolumeIndicator(close, volume)` | `ft.OBV(close, volume)` | Exact match | +| `CCIIndicator(high, low, close, window=N)` | `ft.CCI(high, low, close, timeperiod=N)` | Exact match | + +## Running the Cross-Library Tests + +Cross-library comparison tests live in `tests/integration/test_vs_ta.py`. +They are automatically **skipped** when `ta` is not installed. + +```bash +# Install ta first +pip install ta + +# Run comparison tests +pytest tests/integration/test_vs_ta.py -v +``` + +## Known Differences + +- **EMA seeding**: `ta` uses pandas `ewm` with `adjust=True` by default, which + produces different warm-up values. Results converge after `2 × timeperiod` bars. +- **ATR**: `ta` uses a simple rolling mean for ATR by default; ferro-ta uses + Wilder's smoothing (same as TA-Lib). Values converge after the warm-up window. +- **STOCH**: `ta` and ferro-ta use different default smoothing periods. Pass + matching `window` / `smooth_window` values to get tail convergence. + +## Performance Comparison + +ferro-ta is significantly faster than `ta` for large arrays because the core +computation is written in Rust: + +```bash +pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json +``` + +`ta` is a pure-Python/pandas library; ferro-ta processes 100k-bar arrays +in microseconds vs milliseconds for pandas-based implementations. diff --git a/ferro-ta-main/docs/compatibility/talib.md b/ferro-ta-main/docs/compatibility/talib.md new file mode 100644 index 0000000..d33d14b --- /dev/null +++ b/ferro-ta-main/docs/compatibility/talib.md @@ -0,0 +1,27 @@ +# Compatibility: ferro-ta vs TA-Lib + +See the full migration guide at [docs/migration_talib.rst](../migration_talib.rst). + +ferro-ta is designed as a **drop-in replacement** for TA-Lib (`talib` Python package) for the most commonly used indicators. + +## Quick Reference + +```python +# TA-Lib +import talib +result = talib.SMA(close, timeperiod=14) + +# ferro-ta (identical API) +import ferro_ta +result = ferro_ta.SMA(close, timeperiod=14) +``` + +Full migration guide including all indicator mappings, known differences, and step-by-step migration: [migration_talib.rst](../migration_talib.rst) + +## Running Cross-Library Tests + +```bash +# Requires TA-Lib C library + talib Python package +pip install TA-Lib +pytest tests/integration/test_vs_talib.py -v +``` diff --git a/ferro-ta-main/docs/compatibility/tulipy.md b/ferro-ta-main/docs/compatibility/tulipy.md new file mode 100644 index 0000000..d1ee421 --- /dev/null +++ b/ferro-ta-main/docs/compatibility/tulipy.md @@ -0,0 +1,140 @@ +# ferro-ta ↔ Tulipy Compatibility + +[Tulipy](https://github.com/cirla/tulipy) is the Python binding for +[Tulip Indicators](https://tulipindicators.org/) — 104 technical analysis +functions written in pure ANSI C99, designed for absolute speed with zero +external dependencies. + +--- + +## Key architectural differences + +| Aspect | ferro-ta | Tulipy | +|--------|---------|--------| +| **Backend** | Rust/C + SIMD | ANSI C99 | +| **Input type** | NumPy array or list | `np.float64` contiguous array | +| **Output length** | Same as input (NaN-padded) | Truncated (lookback bars shorter) | +| **NaN handling** | Pads warmup with NaN | Strips warmup entirely | +| **Multi-output** | Returns tuple | Returns tuple | +| **Pandas support** | Yes (via `ArrayLike`) | No | +| **Streaming** | Yes (StreamingXxx classes) | No | + +--- + +## Output length difference + +Tulipy **truncates** output instead of NaN-padding. When comparing results +you must align by the **trailing** elements: + +```python +import tulipy as ti +import ferro_ta +import numpy as np + +close = np.ascontiguousarray(np.random.randn(100).cumsum() + 100, dtype=np.float64) + +ti_sma = ti.sma(close, period=20) # len = 81 +ft_sma = ferro_ta.SMA(close, timeperiod=20) # len = 100 (19 leading NaN) + +# Align: compare last 81 values +n = len(ti_sma) +assert np.allclose(ti_sma, ft_sma[-n:][np.isfinite(ft_sma[-n:])], atol=1e-8) +``` + +--- + +## Function signature mapping + +Tulipy uses lowercase function names. The `period` argument is always a +positional-or-keyword integer. + +| Indicator | ferro-ta | Tulipy | +|-----------|---------|--------| +| SMA | `SMA(close, timeperiod=20)` | `sma(close, period=20)` | +| EMA | `EMA(close, timeperiod=20)` | `ema(close, period=20)` | +| WMA | `WMA(close, timeperiod=14)` | `wma(close, period=14)` | +| RSI | `RSI(close, timeperiod=14)` | `rsi(close, period=14)` | +| MACD | `MACD(close, 12, 26, 9)` | `macd(close, short_period=12, long_period=26, signal_period=9)` | +| BBANDS | `BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)` → (upper, mid, lower) | `bbands(close, period=20, stddev=2.0)` → (lower, mid, upper) ⚠️ reversed! | +| ATR | `ATR(high, low, close, timeperiod=14)` | `atr(high, low, close, period=14)` | +| OBV | `OBV(close, volume)` | `obv(close, volume)` | +| CCI | `CCI(high, low, close, timeperiod=14)` | `cci(high, low, close, period=14)` | +| WILLR | `WILLR(high, low, close, timeperiod=14)` | `willr(high, low, close, period=14)` | +| STOCH | `STOCH(high, low, close, 5, 3, 3)` | `stoch(high, low, close, ...)` | +| HMA | Not supported | `hma(close, period=14)` | +| DEMA | `DEMA(close, timeperiod=30)` | `dema(close, period=30)` | +| TEMA | `TEMA(close, timeperiod=30)` | `tema(close, period=30)` | +| AROON | `AROONOSC(high, low, timeperiod=14)` | `aroonosc(high, low, period=14)` | +| MFI | `MFI(high, low, close, volume, timeperiod=14)` | `mfi(high, low, close, volume, period=14)` | +| TRANGE | `TRANGE(high, low, close)` | `tr(high, low, close)` | + +⚠️ **BBANDS tuple order**: Tulipy returns `(lower, middle, upper)`; +ferro-ta and TA-Lib return `(upper, middle, lower)`. + +--- + +## Memory requirements + +Tulipy requires **strictly contiguous** `np.float64` arrays. Passing a +Pandas Series slice or a non-contiguous array causes an error: + +```python +# Wrong — may be a non-contiguous view +close = df["close"].values +ti.sma(close, period=20) # may raise ValueError + +# Correct — explicit contiguous cast +close = np.ascontiguousarray(df["close"].values, dtype=np.float64) +ti.sma(close, period=20) # always works +``` + +ferro-ta accepts any `ArrayLike` and handles the conversion internally. + +--- + +## Numerical accuracy + +Tulipy and ferro-ta agree closely for SMA, WMA, and other non-recursive +indicators (differences < 1e-8). For EMA-based indicators the first +`timeperiod` values differ due to initialisation seed choice: + +- **Tulipy**: uses the first data value as the EMA seed. +- **ferro-ta**: follows TA-Lib convention (SMA of first `timeperiod` bars). + +Values converge after approximately 2–3× the `timeperiod`. + +--- + +## Speed comparison + +On 10,000 bars (median µs, Apple M-series): + +| Indicator | ferro-ta | Tulipy | Winner | +|-----------|--------:|-------:|--------| +| SMA | 16.7 | 21.2 | ferro-ta | +| MACD | 70.4 | 30.2 | Tulipy | +| ATR | 51.4 | 27.6 | Tulipy | + +Tulipy's C99 implementation excels for recursive indicators (ATR, MACD). +ferro-ta is faster for sliding-window indicators (SMA) thanks to SIMD +vectorisation. + +--- + +## Migration guide + +```python +# FROM Tulipy +import tulipy as ti +import numpy as np + +close = np.ascontiguousarray(close_series.values, dtype=np.float64) +sma_values = ti.sma(close, period=20) # length: n - 19 + +# TO ferro-ta (drop-in, same numeric result in the tail) +import ferro_ta + +sma_values = ferro_ta.SMA(close, timeperiod=20) # length: n (19 leading NaN) +# Strip warmup if needed: +sma_values = sma_values[~np.isnan(sma_values)] +``` diff --git a/ferro-ta-main/docs/conf.py b/ferro-ta-main/docs/conf.py new file mode 100644 index 0000000..7d7d62b --- /dev/null +++ b/ferro-ta-main/docs/conf.py @@ -0,0 +1,98 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import os +import re +import sys +from pathlib import Path + +try: + import tomllib +except ImportError: # pragma: no cover + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: # pragma: no cover + tomllib = None # type: ignore[assignment] + +# Add the python source directory so autodoc can import ferro_ta +# Only add if ferro_ta is not already installed (e.g. from a wheel in CI) +try: + import ferro_ta # noqa: F401 +except ImportError: + sys.path.insert(0, os.path.abspath("../python")) + +# -- Project information ------------------------------------------------------- +project = "ferro-ta" +copyright = "2024, pratikbhadane24" +author = "pratikbhadane24" + + +def _default_release() -> str: + if tomllib is None: + return "0+unknown" + pyproject_toml = Path(__file__).resolve().parents[1] / "pyproject.toml" + try: + if tomllib is not None: + with pyproject_toml.open("rb") as handle: + data = tomllib.load(handle) + return data.get("project", {}).get("version", "0+unknown") + text = pyproject_toml.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + if match: + return match.group(1) + return "0+unknown" + except Exception: + return "0+unknown" + + +# Version from env (e.g. set in CI from git tag) or default to pyproject.toml +release = os.environ.get("FERRO_TA_VERSION", _default_release()) +version = release + +# -- General configuration ---------------------------------------------------- +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", # Google / NumPy-style docstrings + "sphinx.ext.autosummary", + "sphinx.ext.intersphinx", +] + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable", None), + "pandas": ("https://pandas.pydata.org/docs", None), +} + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- Options for HTML output -------------------------------------------------- +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] +html_title = "ferro-ta Documentation" +html_short_title = "ferro-ta" + +# -- autodoc ------------------------------------------------------------------ +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} +autodoc_typehints = "description" +napoleon_google_docstring = False +napoleon_numpy_docstring = True + +# Suppress autodoc import warnings for modules that can't be loaded without +# the compiled Rust extension (_ferro_ta). These are expected when building +# docs without the wheel; the documented API is still accurate. +# Also suppress duplicate object descriptions that arise when Rust-backed +# streaming classes (defined in ferro_ta._ferro_ta) are re-exported through +# ferro_ta.streaming — autodoc sees them in both modules. +suppress_warnings = [ + "autodoc.import_object", + "ref.doc", + "py.duplicate", +] diff --git a/ferro-ta-main/docs/contributing.rst b/ferro-ta-main/docs/contributing.rst new file mode 100644 index 0000000..4ebf9ef --- /dev/null +++ b/ferro-ta-main/docs/contributing.rst @@ -0,0 +1,113 @@ +Contributing +============ + +Thank you for your interest in contributing to ferro-ta! + +This page summarises how to get started. The full details are in +`CONTRIBUTING.md `_ +at the repository root. + +.. contents:: + :local: + :depth: 2 + + +Development setup +----------------- + +Prerequisites: Rust stable toolchain, Python 3.10+, and ``maturin``. + +.. code-block:: bash + + git clone https://github.com/pratikbhadane24/ferro-ta.git + cd ferro-ta + pip install maturin numpy pytest pytest-cov + maturin develop --release + pytest tests/ + + +Git hooks and pre-push checks +----------------------------- + +Install the repository-managed hooks after setting up the environment: + +.. code-block:: bash + + make hooks + +Run the same push gate manually with: + +.. code-block:: bash + + make prepush + +You can scope it to selected checks while iterating: + +.. code-block:: bash + + make prepush CHECKS="version changelog python_lint" + + +Adding a new indicator +----------------------- + +1. **Rust** — implement the function in the appropriate ``src//`` + directory (e.g. ``src/overlap/mod.rs`` and ``src/overlap/sma.rs``). Follow + the existing patterns: slice inputs, ``Vec`` output, leading NaN for + warm-up bars, a ``#[pyfunction]`` decorator, and registration in the + module's ``register(m)`` function. + +2. **Python** — add a thin wrapper in the matching ``python/ferro_ta/*.py`` + module using the ``_to_f64`` helper. Export it in ``__all__``. + +3. **Re-export** — add the function to ``python/ferro_ta/__init__.py``'s + ``__all__`` list and import block. + +4. **Type stub** — add a type annotation to ``python/ferro_ta/__init__.pyi``. + +5. **Tests** — add at least one test class in ``tests/test_ferro_ta.py`` + covering output length, NaN count, and a known-value check. + +6. **README** — add a row to the appropriate accuracy table. + + +Code style +---------- + +- Rust: ``cargo fmt`` (enforced in CI) and ``cargo clippy -- -D warnings`` +- Python: PEP 8; function names in UPPER_CASE to match TA-Lib convention. +- All public Python functions should have NumPy-style docstrings. + + +Running tests +------------- + +.. code-block:: bash + + # Python tests + pytest tests/ -v + + # Rust format check + cargo fmt --check + + # Rust lints + cargo clippy --release -- -D warnings + + # Optional: TA-Lib comparison tests (requires ta-lib installed) + pytest tests/test_vs_talib.py -v + + +Type checking +------------- + +The package is typed (PEP 561). To run mypy:: + + pip install mypy numpy + mypy python/ferro_ta --ignore-missing-imports + + +Questions +--------- + +Open a GitHub Issue or Discussion. For security vulnerabilities see +`SECURITY.md `_. diff --git a/ferro-ta-main/docs/derivatives-analytics.md b/ferro-ta-main/docs/derivatives-analytics.md new file mode 100644 index 0000000..ed86658 --- /dev/null +++ b/ferro-ta-main/docs/derivatives-analytics.md @@ -0,0 +1,226 @@ +# Derivatives Analytics + +`ferro-ta` ships a Rust-backed derivatives analytics layer focused on +research, simulation, and risk analysis. All functions are implemented in +Rust core and exposed to Python (via PyO3) and WebAssembly (via wasm-bindgen). + +--- + +## Modules + +### `ferro_ta.analysis.options` + +| Category | Functions | +|---|---| +| **Pricing** | `black_scholes_price`, `black_76_price`, `option_price` | +| **Greeks** | `greeks`, `extended_greeks` | +| **Implied vol** | `implied_volatility`, `iv_rank`, `iv_percentile`, `iv_zscore` | +| **Digital options** | `digital_option_price`, `digital_option_greeks` | +| **American options** | `american_option_price`, `early_exercise_premium` | +| **Smile / surface** | `smile_metrics`, `term_structure_slope`, `expected_move` | +| **Chain helpers** | `label_moneyness`, `select_strike` | +| **Realised vol** | `close_to_close_vol`, `parkinson_vol`, `garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol` | +| **Vol cone** | `vol_cone` | +| **Diagnostics** | `put_call_parity_deviation` | + +### `ferro_ta.analysis.futures` + +- Synthetic forwards and parity diagnostics +- Basis, annualized basis, implied carry, carry spread +- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted +- Curve analytics: calendar spreads, slope, contango summary + +### `ferro_ta.analysis.options_strategy` + +Typed strategy schemas: expiry selectors, strike selectors, multi-leg presets +(`STRADDLE`, `STRANGLE`, `IRON_CONDOR`, `BULL_CALL_SPREAD`, `BEAR_PUT_SPREAD`), +risk controls, cost assumptions, and simulation limits. + +### `ferro_ta.analysis.derivatives_payoff` + +Multi-leg payoff and Greeks aggregation supporting **option**, **future**, and +**stock** instrument types. + +| Function | Description | +|---|---| +| `option_leg_payoff` | Expiry P/L for a single option leg | +| `futures_leg_payoff` | Linear P/L for a futures leg | +| `stock_leg_payoff` | Linear P/L for a stock/equity leg | +| `strategy_payoff` | Aggregate expiry payoff across all legs | +| `strategy_value` | Pre-expiry BSM mid-price value of a multi-leg strategy | +| `aggregate_greeks` | Portfolio-level Greeks across option, futures, and stock legs | + +--- + +## Model conventions + +| Parameter | Convention | +|---|---| +| `model="bsm"` | Underlying is spot; `carry` = continuous dividend yield | +| `model="black76"` | Underlying is the forward price | +| `volatility` / `rate` / `carry` | Decimal annual (e.g. `0.20` = 20 %, `0.05` = 5 %) | +| `time_to_expiry` | Years (e.g. `0.25` = 3 months) | + +--- + +## Quick examples + +### BSM pricing and Greeks + +```python +from ferro_ta.analysis.options import greeks, implied_volatility, option_price + +price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call") +g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +print(price, iv, g.delta, g.gamma) +``` + +### Extended (second-order) Greeks + +```python +from ferro_ta.analysis.options import extended_greeks + +eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +print(eg.vanna, eg.volga, eg.charm, eg.speed, eg.color) +``` + +### Digital options + +```python +from ferro_ta.analysis.options import digital_option_price, digital_option_greeks + +# Cash-or-nothing call at ATM ≈ e^{-rT} * N(d2) ≈ 0.53 +price = digital_option_price(100.0, 100.0, 0.05, 1.0, 0.20, + option_type="call", digital_type="cash_or_nothing") +g = digital_option_greeks(100.0, 100.0, 0.05, 1.0, 0.20, + option_type="call", digital_type="cash_or_nothing") +print(price, g.delta, g.gamma, g.vega) +``` + +### American options (BAW approximation) + +```python +from ferro_ta.analysis.options import american_option_price, early_exercise_premium + +# American put — may have meaningful early exercise premium +american = american_option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put") +premium = early_exercise_premium(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put") +print(american, premium) +``` + +### Historical volatility estimators + +```python +import numpy as np +from ferro_ta.analysis.options import ( + close_to_close_vol, garman_klass_vol, parkinson_vol, + rogers_satchell_vol, yang_zhang_vol, +) + +# Assume daily OHLC arrays of length N +open_p, high_p, low_p, close_p = ... # numpy arrays + +ctc = close_to_close_vol(close_p, window=20) # close-only +park = parkinson_vol(high_p, low_p, window=20) # high-low +gk = garman_klass_vol(open_p, high_p, low_p, close_p, window=20) +rs = rogers_satchell_vol(open_p, high_p, low_p, close_p, window=20) +yz = yang_zhang_vol(open_p, high_p, low_p, close_p, window=20) +``` + +### Volatility cone + +```python +from ferro_ta.analysis.options import vol_cone + +cone = vol_cone(close_p, windows=(21, 42, 63, 126, 252)) +# Overlay current IV against the cone to gauge richness/cheapness +for w, med in zip(cone.windows, cone.median): + print(f"window={int(w):3d} median_rv={med:.1%}") +``` + +### Put-call parity check + +```python +from ferro_ta.analysis.options import option_price, put_call_parity_deviation + +call = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +put = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put") +dev = put_call_parity_deviation(call, put, 100.0, 100.0, 0.05, 1.0) +# dev ≈ 0.0 for BSM-consistent prices; non-zero signals stale/mismatched quotes +``` + +### Expected move + +```python +from ferro_ta.analysis.options import expected_move + +lower, upper = expected_move(100.0, 0.20, days_to_expiry=30) +print(f"Expected ±1σ range: [{100+lower:.2f}, {100+upper:.2f}]") +``` + +### Multi-leg strategies with stock + +```python +import numpy as np +from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff, strategy_value + +# Covered Call: long 100 shares + short 1 OTM call +spot_grid = np.linspace(80, 130, 100) +legs = [ + PayoffLeg("stock", "long", entry_price=100.0), + PayoffLeg("option", "short", option_type="call", + strike=110.0, premium=3.0, volatility=0.20, time_to_expiry=0.25), +] + +# Expiry P/L +payoff = strategy_payoff(spot_grid, legs=legs) + +# Pre-expiry BSM value (T=3 months remaining) +value = strategy_value(spot_grid, legs=legs, time_to_expiry=0.25, volatility=0.20) +``` + +### Futures analytics + +```python +from ferro_ta.analysis.futures import basis, curve_summary + +print(basis(100.0, 103.0)) +print(curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0])) +``` + +--- + +## Instrument types in `PayoffLeg` / `StrategyLeg` + +| `instrument` | Required fields | Payoff | +|---|---|---| +| `"option"` | `option_type`, `strike`, `expiry_selector`, `strike_selector` | `max(φ(S−K), 0) − premium` | +| `"future"` | `entry_price` | `S − entry_price` | +| `"stock"` | `entry_price` | `S − entry_price` (identical to future, no margin) | + +--- + +## Volatility estimator efficiency comparison + +| Estimator | Relative efficiency vs close-to-close | Handles overnight gaps | +|---|---|---| +| Close-to-close | 1× (baseline) | N/A (uses close only) | +| Parkinson | ~5× | No | +| Garman-Klass | ~7.4× | No | +| Rogers-Satchell | ~8× | No | +| Yang-Zhang | ~14× | Yes | + +*Use Yang-Zhang when you have overnight gaps (futures, crypto). Use Parkinson +or Garman-Klass for continuous trading sessions.* + +--- + +## Notes + +- All existing function names (`iv_rank`, `iv_percentile`, `iv_zscore`, `greeks`, + `option_price`, etc.) are preserved — fully backward compatible. +- The derivatives layer is analytics-only: no broker connectivity, order routing, + or execution workflow. +- WASM: all functions in this layer are also exported as WebAssembly bindings + (see `wasm/src/lib.rs`). diff --git a/ferro-ta-main/docs/derivatives.rst b/ferro-ta-main/docs/derivatives.rst new file mode 100644 index 0000000..9ac93ef --- /dev/null +++ b/ferro-ta-main/docs/derivatives.rst @@ -0,0 +1,126 @@ +Derivatives Analytics +===================== + +``ferro-ta`` includes a Rust-backed derivatives layer for analytics, research, +and simulation workflows. The implementation is analytics-only: there is no +broker connectivity, order routing, or execution engine in this package. + +What Is Included +---------------- + +Options analytics +~~~~~~~~~~~~~~~~~ + +- Rolling IV helpers: ``iv_rank``, ``iv_percentile``, ``iv_zscore`` +- Black-Scholes-Merton pricing +- Black-76 pricing +- Greeks: delta, gamma, vega, theta, rho +- Implied volatility inversion +- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity +- Chain helpers: moneyness labels and strike selection by offset or delta + +Futures analytics +~~~~~~~~~~~~~~~~~ + +- Synthetic forwards and parity diagnostics +- Basis, annualized basis, implied carry, carry spread +- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted +- Curve analytics: calendar spreads, slope, contango/backwardation summary + +Strategy and payoff helpers +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Typed strategy schemas for expiry selectors, strike selectors, leg presets, + risk controls, and simulation limits +- Multi-leg payoff aggregation +- Greeks aggregation across option and futures legs + +Conventions +----------- + +- ``model="bsm"`` expects spot as the underlying input. +- ``model="black76"`` expects forward as the underlying input. +- Volatility uses decimal annualized units: ``0.20`` means 20%. +- Rates and carry use decimal annualized units: ``0.05`` means 5%. +- ``time_to_expiry`` is expressed in years. + +Options Example +--------------- + +.. code-block:: python + + from ferro_ta.analysis.options import greeks, implied_volatility, option_price + + price = option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.20, + option_type="call", + model="bsm", + ) + iv = implied_volatility( + price, + 100.0, + 100.0, + 0.05, + 1.0, + option_type="call", + model="bsm", + ) + g = greeks( + 100.0, + 100.0, + 0.05, + 1.0, + 0.20, + option_type="call", + model="bsm", + ) + +Futures Example +--------------- + +.. code-block:: python + + from ferro_ta.analysis.futures import basis, curve_summary, synthetic_forward + + front_basis = basis(100.0, 103.0) + synthetic = synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5) + curve = curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]) + +Strategy and Payoff Example +--------------------------- + +.. code-block:: python + + from ferro_ta.analysis.derivatives_payoff import PayoffLeg, aggregate_greeks, strategy_payoff + + legs = [ + PayoffLeg( + instrument="option", + side="long", + option_type="call", + strike=100.0, + premium=5.0, + volatility=0.20, + time_to_expiry=0.5, + ), + PayoffLeg( + instrument="future", + side="long", + entry_price=100.0, + ), + ] + + payoff = strategy_payoff([90.0, 100.0, 110.0], legs=legs) + portfolio_greeks = aggregate_greeks(100.0, legs=legs) + +Related Modules +--------------- + +- :mod:`ferro_ta.analysis.options` +- :mod:`ferro_ta.analysis.futures` +- :mod:`ferro_ta.analysis.options_strategy` +- :mod:`ferro_ta.analysis.derivatives_payoff` diff --git a/ferro-ta-main/docs/error_handling.rst b/ferro-ta-main/docs/error_handling.rst new file mode 100644 index 0000000..846ae75 --- /dev/null +++ b/ferro-ta-main/docs/error_handling.rst @@ -0,0 +1,79 @@ +Error Handling and Validation +============================= + +ferro-ta uses a consistent error model so you can catch and handle failures in a +predictable way. + +Exception hierarchy +------------------- + +All ferro-ta–specific exceptions inherit from :exc:`ferro_ta.FerroTAError` and the +corresponding built-in type so that existing ``except ValueError`` code keeps +working: + +- **FerroTAError** — base for all ferro-ta exceptions +- **FerroTAValueError** — invalid parameter values (e.g. ``timeperiod < 1``, + ``fastperiod >= slowperiod`` for MACD). Inherits from :exc:`ValueError`. +- **FerroTAInputError** — invalid input arrays (mismatched lengths, wrong shape, + or opt-in strict checks). Inherits from :exc:`ValueError`. + +Example: + +.. code-block:: python + + from ferro_ta import SMA, FerroTAValueError, FerroTAInputError + + try: + SMA(close, timeperiod=0) + except FerroTAValueError as e: + print(e) # "timeperiod must be >= 1, got 0" + + try: + SMA(open_arr, timeperiod=5) # if open_arr has different length + except FerroTAInputError as e: + print(e) + +Validation in wrappers +---------------------- + +Every indicator wrapper validates parameters and inputs before calling the Rust +engine: + +- **Period parameters** (e.g. ``timeperiod``, ``fastperiod``, ``slowperiod``) are + checked with :func:`ferro_ta.exceptions.check_timeperiod` and must be >= 1 + (or >= 2 where the algorithm requires it, e.g. MAVP ``minperiod``). +- **Multiple arrays** (e.g. open, high, low, close, volume) are checked with + :func:`ferro_ta.exceptions.check_equal_length` so all have the same length. + +Any error raised by the Rust extension (e.g. invalid value or bad array) is +re-raised as :exc:`FerroTAValueError` or :exc:`FerroTAInputError` with the same +message, so you can rely on the ferro-ta exception hierarchy. + +NaN and Inf +----------- + +By default, ferro-ta **propagates** NaN and Inf in input arrays: output values +that depend on a NaN/Inf input will themselves be NaN/Inf. No exception is +raised for NaN or Inf in the input. + +If you need strict behaviour (no NaN/Inf), call +:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them to +an indicator. + +Empty and short arrays +---------------------- + +Indicators that require a minimum number of bars (e.g. SMA with ``timeperiod=5`` +needs at least 5 elements) may return an array of NaN or raise if the Rust layer +rejects the input. You can use :func:`ferro_ta.exceptions.check_min_length` to +enforce a minimum length before calling an indicator. + +Helper reference +---------------- + +- :func:`ferro_ta.exceptions.check_timeperiod` — raise if a period parameter is below minimum +- :func:`ferro_ta.exceptions.check_equal_length` — raise if supplied arrays have different lengths +- :func:`ferro_ta.exceptions.check_finite` — raise if an array contains NaN or Inf (opt-in strict) +- :func:`ferro_ta.exceptions.check_min_length` — raise if an array is shorter than required + +See the :mod:`ferro_ta.exceptions` API for full signatures and examples. diff --git a/ferro-ta-main/docs/extended.rst b/ferro-ta-main/docs/extended.rst new file mode 100644 index 0000000..5b9677d --- /dev/null +++ b/ferro-ta-main/docs/extended.rst @@ -0,0 +1,10 @@ +Extended Indicators +=================== + +Extended indicators go beyond the TA-Lib standard set. + +.. automodule:: ferro_ta.extended + :no-index: + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/gpu-backend.md b/ferro-ta-main/docs/gpu-backend.md new file mode 100644 index 0000000..c8e15cf --- /dev/null +++ b/ferro-ta-main/docs/gpu-backend.md @@ -0,0 +1,134 @@ +# GPU Backend (PyTorch) + +This document describes the optional GPU-accelerated backend for **ferro-ta** powered +by [PyTorch](https://pytorch.org/). + +--- + +## Goals + +- Offer a drop-in GPU path for a small subset of indicators (SMA, EMA, RSI) for users + who process very large arrays (millions of bars or thousands of symbols in parallel). +- Keep the default install CPU-only: no GPU dependency unless the user opts in. +- Maintain API transparency: `torch.Tensor` in → `torch.Tensor` out; + `numpy.ndarray` in → `numpy.ndarray` out. +- Support both **CUDA** (NVIDIA) and **MPS** (Apple Silicon). + +--- + +## Supported Indicators + +| Indicator | Module | Notes | +|---|---|---| +| `sma` | `ferro_ta.gpu` | cumsum-based O(n) rolling mean; native PyTorch | +| `ema` | `ferro_ta.gpu` | SMA-seeded; recurrence on CPU for numerical fidelity | +| `rsi` | `ferro_ta.gpu` | diffs on GPU; Wilder smoothing on CPU | + +All other ferro-ta indicators fall back to the CPU path automatically when called +through the top-level `ferro_ta` namespace. + +--- + +## Installation + +**Default (CPU-only):** + +```bash +pip install ferro-ta +``` + +**With GPU support (PyTorch):** + +```bash +pip install "ferro-ta[gpu]" +``` + +This installs `torch>=2.0`. For CUDA or MPS, install the appropriate PyTorch build +from [pytorch.org](https://pytorch.org/get-started/locally/): + +```bash +# CUDA 12.x (example) +pip install torch --index-url https://download.pytorch.org/whl/cu121 + +# Apple Silicon (MPS) — often included in default pip install +pip install torch +``` + +--- + +## Usage + +```python +import torch +from ferro_ta.gpu import sma, ema, rsi + +# Build a tensor on GPU (CUDA or MPS on Apple Silicon) +close_gpu = torch.tensor( + [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33], + device="cuda", # or device="mps" on Apple Silicon + dtype=torch.float64, +) + +# GPU-accelerated SMA — result is also a torch.Tensor +sma_out = sma(close_gpu, timeperiod=5) +print(type(sma_out)) # +print(sma_out.cpu().numpy()) # same values as CPU SMA + +# RSI on GPU +rsi_out = rsi(close_gpu, timeperiod=5) + +# Fall back to CPU automatically when input is numpy +import numpy as np +close_cpu = np.array([44.34, 44.09, 44.15, 43.61, 44.33]) +sma_cpu = sma(close_cpu, timeperiod=3) +print(type(sma_cpu)) # +``` + +--- + +## Limitations + +1. **Only 3 indicators supported.** SMA, EMA, RSI. The full set of 160+ indicators + falls back to the CPU path. Adding more GPU indicators is planned for future work. + +2. **Transfer overhead.** Moving data from CPU RAM to GPU memory and back dominates for + small arrays (< ~100k elements). The GPU path is faster only when data is already + on the device or for very large arrays. + +3. **float64.** PyTorch tensors are supported; dtype conversion is performed + automatically for integer inputs. + +4. **EMA and RSI recurrence is on CPU.** To guarantee exact Wilder-smoothing parity + with the CPU implementation, the recurrence loop runs on the CPU after computing + diffs/seeds on the GPU. A future release may implement a fully native GPU kernel. + +5. **No OOM handling.** For extremely large arrays the GPU may run out of memory; + no graceful fallback is implemented. + +--- + +## Benchmarks + +Measured on an NVIDIA RTX 3080 (10 GB VRAM) with CUDA 12.2, Python 3.11, +PyTorch 2.x. Array size: **1,000,000 elements**. + +| Indicator | CPU (NumPy/Rust) | GPU (PyTorch) | Speedup | Notes | +|---|---|---|---|---| +| `sma` (period 30) | 0.4 ms | 0.9 ms | 0.4× | Transfer overhead dominates | +| `ema` (period 30) | 0.6 ms | 1.2 ms | 0.5× | Recurrence on CPU; no GPU gain | +| `rsi` (period 14) | 1.1 ms | 1.4 ms | 0.8× | Diffs on GPU; recurrence on CPU | + +> **Key finding:** For 1M-element arrays, the GPU path is **not faster** than the +> optimised Rust/CPU path due to the cost of host↔device memory transfers. The GPU +> path is most useful when (a) data is already on the GPU, or (b) the same kernel +> is launched many times without re-transferring data. + +The benchmark script is in `benchmarks/bench_gpu.py`. + +--- + +## Future Work + +- Implement fully native GPU kernels for EMA and RSI to avoid CPU round-trips. +- Extend to batch operations (running 1000+ symbols in parallel on GPU). +- Add optional RAPIDS cuDF or Polars GPU integration for dataframe-level workflows. diff --git a/ferro-ta-main/docs/guides/dtw.md b/ferro-ta-main/docs/guides/dtw.md new file mode 100644 index 0000000..c7a825f --- /dev/null +++ b/ferro-ta-main/docs/guides/dtw.md @@ -0,0 +1,80 @@ +# Dynamic Time Warping + +ferro-ta ships three DTW entry points. Pick the one that matches your +workload — the distance-only path is measurably faster than the one that +reconstructs the warping path, and `BATCH_DTW` parallelises over rows. + +## Quick reference + +| Function | Returns | When to use | +|---|---|---| +| `DTW_DISTANCE(a, b, window=None)` | `float` | You only need the distance. Fastest. | +| `DTW(a, b, window=None)` | `(float, ndarray[N, 2])` | You need the alignment path for plotting or downstream analysis. | +| `BATCH_DTW(matrix, reference, window=None)` | `ndarray[N]` | You have N candidate series and one reference; uses rayon. | + +## Distance convention + +ferro-ta's DTW uses squared-Euclidean local cost accumulated along the +optimal path, with a single `sqrt()` applied at the end. This matches +`dtaidistance.dtw.distance()` to within floating-point tolerance (parity +tests assert numerical agreement, not bitwise identity). Example: + +```python +>>> import ferro_ta as fta +>>> fta.DTW_DISTANCE([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]) +1.4142135623730951 # == sqrt(2), same as dtaidistance +``` + +If you are migrating from a library that uses absolute-difference local +cost without the final sqrt (e.g. `fastdtw`'s default), your numbers will +not line up. That is a choice ferro-ta made for parity with the +scientific-Python ecosystem. + +## Window constraint (Sakoe-Chiba band) + +Passing `window=w` constrains the DP to cells where `|i - j| < w`. This +turns the O(n·m) cost into O(n·w), which is typically a 5–20× speedup for +realistic `w`. A narrower band can only *increase* the distance, so +`window=` is safe to use whenever your series are roughly aligned. + +```python +# Unconstrained +fta.DTW_DISTANCE(a, b) + +# Constrained: warping may shift up to 5 positions +fta.DTW_DISTANCE(a, b, window=5) +``` + +## Batch usage + +`BATCH_DTW` compares each row of a 2-D matrix against one reference +series, in parallel: + +```python +import numpy as np +import ferro_ta as fta + +reference = np.random.random(500) +candidates = np.random.random((1000, 500)) + +distances = fta.BATCH_DTW(candidates, reference, window=20) +nearest = int(np.argmin(distances)) +``` + +Parallelism is via rayon; no thread-pool configuration is needed on the +Python side. For the sequence lengths ferro-ta targets (thousands of +bars, hundreds to low thousands of candidates), batch-parallel classic +DTW beats FastDTW-style approximations. + +## Edge cases + +- **Empty input:** raises `FerroTAInputError`. +- **NaN in input:** propagates to the output (matches IEEE 754). Call + `ferro_ta.core.exceptions.check_finite()` first if you want to fail + loudly instead. +- **Different-length series:** fully supported. The path array length + is bounded by `max(n, m) <= len(path) <= n + m - 1`. + +## See also + +- `tests/unit/indicators/test_statistic.py` — parity tests against `dtaidistance`. diff --git a/ferro-ta-main/docs/guides/simd.md b/ferro-ta-main/docs/guides/simd.md new file mode 100644 index 0000000..31fd46a --- /dev/null +++ b/ferro-ta-main/docs/guides/simd.md @@ -0,0 +1,92 @@ +# SIMD acceleration + +ferro-ta accelerates hot reductions with **runtime CPU-feature dispatch** +via the [`multiversion`](https://crates.io/crates/multiversion) crate. Each +dispatched function is compiled into several variants — baseline, SSE, +AVX2/FMA, AVX-512 on x86_64; NEON on aarch64 — and the fastest one the +**current** CPU supports is chosen at load time via CPUID. + +## Why dispatch instead of `-C target-cpu` + +A static `RUSTFLAGS=-C target-cpu=x86-64-v3` build *requires* AVX2 on the +running CPU; on an older chip it crashes with an illegal instruction +(SIGILL). Runtime dispatch instead ships every code path in one binary and +picks at runtime, so a single artifact: + +- runs on **any** CPU of the target architecture (no SIGILL on pre-AVX2 + hardware), and +- still uses wide vector units where the hardware has them. + +That property is what lets the **same** wheel / Docker image / crate run +across a heterogeneous fleet. + +## When it helps + +SIMD helps indicators whose inner loop is a reduction over contiguous +`f64` data — e.g. the initial window sum that seeds SMA, the `(T, S)` seed +for WMA, and similar fixed-window reductions. It does **not** help: + +- The O(n) streaming recurrences (`window_sum += new - old`): each step + depends on the previous one, so they are inherently sequential. +- Branchy inner loops (SAR, candlestick patterns). +- Streaming classes (a single-bar update is one or two ops). + +The shared primitives live in `crates/ferro_ta_core/src/simd.rs` +(`sum`, `wma_seed`). They accumulate into independent lanes before a final +horizontal combine — that lane independence is what allows the optimizer to +vectorize each CPU-feature variant. A consequence is that results differ +from a strict left-to-right sum by a few ULPs, well inside every +indicator's documented tolerance. + +## The `simd` feature + +Dispatch is gated behind the `simd` Cargo feature, which is **on by +default**: + +```bash +# default build — runtime dispatch enabled +cargo build -p ferro_ta_core --release + +# pure-scalar build (debugging / baseline benchmarking) +cargo build -p ferro_ta_core --release --no-default-features +``` + +For Python, wheels published to PyPI are built with the default features, +so `pip install ferro-ta` ships the dispatched fast path with no action on +your part. To build a pure-scalar extension from source: + +```bash +maturin develop --release --no-default-features +``` + +## Measured speedups + +The nightly `benchmarks/bench_simd.py` job (see +`.github/workflows/nightly-bench.yml`) builds the extension twice — once +with `--no-default-features` (pure scalar) and once with `--features simd` +(dispatch) — and reports the per-indicator delta. Numbers are regenerated +on every run and vary with hardware; treat any table in a PR as a snapshot, +not a contract. The dispatched kernels here target correctness-preserving +reductions, so gains are modest on the sliding-window indicators and larger +on full-array reductions. + +## Adding a SIMD-optimized indicator + +1. Write and test the **scalar** implementation first — it is the ground + truth. +2. If the hot path is a contiguous `f64` reduction, route it through a + `crate::simd` primitive, or wrap a new helper in + `#[multiversion::multiversion(targets = "simd")]` with the loop body + accumulating into independent lanes. +3. Add a parity test comparing the dispatched result against the strict + scalar reference within tolerance (see `simd.rs` tests for the pattern). +4. Benchmark scalar vs dispatch via `bench_simd.py`. Only keep the SIMD + path if it wins — alignment and tail-handling overhead can make a naive + vectorization *lose* to scalar. + +## See also + +- `crates/ferro_ta_core/src/simd.rs` — dispatched primitives and tests. +- `benches/indicators.rs` — criterion suite. +- `crates/ferro_ta_core/Cargo.toml` `[features] simd = ["dep:multiversion"]` + — the gate (default-on). diff --git a/ferro-ta-main/docs/index.rst b/ferro-ta-main/docs/index.rst new file mode 100644 index 0000000..d94da9a --- /dev/null +++ b/ferro-ta-main/docs/index.rst @@ -0,0 +1,108 @@ +ferro-ta Documentation +====================== + +.. toctree:: + :maxdepth: 2 + :caption: Core Library + + quickstart + migration_talib + support_matrix + pandas_api + error_handling + api/index + streaming + batch + extended + +.. toctree:: + :maxdepth: 2 + :caption: Evidence and Releases + + benchmarks + changelog + +.. toctree:: + :maxdepth: 2 + :caption: Adjacent and Experimental + + derivatives + adjacent_tooling + plugins + contributing + +Overview +-------- + +**ferro-ta** is a Rust-powered Python technical analysis library focused on a +TA-Lib-compatible API for NumPy-centered workloads. + +.. important:: + + Performance varies by indicator, array layout, warmup, build flags, and + machine. ferro-ta is often faster on selected indicators, not universally + faster. See :doc:`benchmarks` for the reproducible workflow, methodology + notes, and the indicators where TA-Lib still wins or ties in the current + checked-in artifact. + +Core library: + +- 160+ indicators covering all TA-Lib categories +- TA-Lib-style imports such as ``ferro_ta.SMA(close, timeperiod=20)`` +- Pre-built wheels for the supported Python/OS matrix +- Pure Rust core library (``crates/ferro_ta_core``) — no PyO3 / numpy dependency +- Batch execution API — run indicators on 2-D arrays of multiple series +- Streaming / bar-by-bar API for live trading +- Transparent pandas.Series support +- Type stubs (.pyi) for IDE auto-completion +- 10 extended indicators not in TA-Lib (VWAP, Supertrend, Ichimoku Cloud, ...) + +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 `_ +- WASM, plugins, and other optional surfaces — see :doc:`adjacent_tooling` + +Installation +~~~~~~~~~~~~ + +.. code-block:: bash + + pip install ferro-ta + +Quick Start +~~~~~~~~~~~ + +.. code-block:: python + + import numpy as np + from ferro_ta import SMA, EMA, RSI, MACD, BBANDS + + close = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 12.5]) + print(SMA(close, timeperiod=3)) + + # Batch: run SMA on 5 symbols at once + from ferro_ta.batch import batch_sma + data = np.random.rand(100, 5) + result = batch_sma(data, timeperiod=10) + +Further Reading +~~~~~~~~~~~~~~~ + +- `Architecture `_ — Rust/Python layout, two-crate design, binding flow. +- `Performance Guide `_ — when to use raw numpy vs pandas/polars, batch notes, tips. +- `API Stability `_ — stability tiers, versioning, and deprecation policy. +- :doc:`support_matrix` — parity status, tested wheel targets, supported Python versions, and experimental modules. +- `Rust-First Policy `_ — all compute logic belongs in Rust; how to add new indicators. +- `Out-of-Core Execution `_ — chunked processing and Dask integration. +- :doc:`derivatives` — IV helpers, options pricing/Greeks/IV, futures analytics, strategy schemas, and payoff helpers. +- :doc:`adjacent_tooling` — optional surfaces such as derivatives, MCP, WASM, GPU, plugins, and agent-oriented integrations. + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/ferro-ta-main/docs/mcp.md b/ferro-ta-main/docs/mcp.md new file mode 100644 index 0000000..35ff982 --- /dev/null +++ b/ferro-ta-main/docs/mcp.md @@ -0,0 +1,191 @@ +# MCP Server + +ferro-ta ships an optional MCP (Model Context Protocol) server built on the +official Python SDK's FastMCP layer. The server now exposes the broad public +ferro-ta callable surface instead of a tiny hand-picked subset. + +That means MCP clients can use: + +- Exact top-level ferro-ta exports such as `SMA`, `RSI`, `MACD`, `about`, + `methods`, `info`, `benchmark`, and `traced` +- Non-top-level public tools such as `compute_indicator`, `run_backtest`, + `check_cross`, `aggregate_ticks`, `TickAggregator`, and `AlertManager` +- Legacy lowercase convenience aliases: `sma`, `ema`, `rsi`, `macd`, + and `backtest` +- Generic instance tools for stateful classes and stored callables: + `list_instances`, `describe_instance`, `call_instance_method`, + `call_stored_callable`, and `delete_instance` + +--- + +## Installation + +Install the optional MCP extra: + +```bash +pip install "ferro-ta[mcp]" +``` + +If you are working from this repository, you can install the same extra into +the project environment with: + +```bash +uv sync --extra mcp +``` + +--- + +## Running the server + +Run the server over stdio: + +```bash +python -m ferro_ta.mcp +``` + +The command exits immediately with an install hint if the optional `mcp` +dependency is missing. + +--- + +## Connect in Cursor + +Add the server to Cursor's MCP settings: + +```json +{ + "mcpServers": { + "ferro-ta": { + "command": "python", + "args": ["-m", "ferro_ta.mcp"], + "description": "ferro-ta technical analysis tools" + } + } +} +``` + +You can place this in your user settings JSON or in a workspace-level +`.cursor/mcp.json`. + +--- + +## Tool naming + +The MCP server prefers the real ferro-ta API names. + +- Use exact public names when possible, for example `SMA`, `MACD`, + `compute_indicator`, `trade_stats`, `TickAggregator`, or `AlertManager` +- Use the legacy lowercase aliases only when you want the old MCP-friendly + shortcuts and result shapes +- Use `about`, `methods`, `indicators`, and `info` to discover what is + available from inside an MCP client + +--- + +## Stateful classes and object references + +Class tools return stored object references instead of plain text placeholders. +For example, calling `TickAggregator` or `AlertManager` returns a payload like: + +```json +{ + "instance_id": "tickaggregator-0001", + "type": "ferro_ta.data.aggregation.TickAggregator", + "repr": "TickAggregator(rule='tick:2')" +} +``` + +Use that `instance_id` with: + +- `describe_instance` to inspect the stored object and list public methods +- `call_instance_method` to call methods like `aggregate`, `update`, + `run_backtest`, or `to_dict` +- `delete_instance` to remove stored objects when you are done + +If a tool returns a stored callable, use `call_stored_callable`. + +--- + +## Callable references + +Some ferro-ta APIs accept other callables, for example `benchmark`, +`log_call`, `traced`, or `multi_timeframe(indicator=...)`. + +Pass public ferro-ta callables using: + +```json +{"callable": "SMA"} +``` + +Pass stored objects using: + +```json +{"instance_id": "function-0001"} +``` + +--- + +## Example prompts + +Once connected, you can ask an MCP-compatible client things like: + +> "Run `SMA` with `close=[100, 101, 102, 103, 104]` and `timeperiod=3`." + +> "Use `compute_indicator` to calculate `MACD` for this close series." + +> "Call `about` and summarize the current ferro-ta API surface." + +> "Create a `TickAggregator` with `rule='tick:50'`, aggregate this tick data, +> then delete the instance." + +> "Benchmark `SMA` over this price series using a callable reference." + +--- + +## Programmatic use + +Use the server entrypoint: + +```python +from ferro_ta.mcp import create_server + +server = create_server() +# server.run(transport="stdio") +``` + +Or call the handlers directly without starting the server: + +```python +from ferro_ta.mcp import handle_call_tool, handle_list_tools +import json + +tools = handle_list_tools() +print(len(tools["tools"])) + +close = [100, 101, 102, 103, 104] +result = handle_call_tool("SMA", {"close": close, "timeperiod": 3}) +print(json.loads(result["content"][0]["text"])) + +aggregator = json.loads( + handle_call_tool("TickAggregator", {"rule": "tick:2"})["content"][0]["text"] +) +bars = handle_call_tool( + "call_instance_method", + { + "instance_id": aggregator["instance_id"], + "method": "aggregate", + "args": [{"price": [1, 2, 3, 4], "size": [1, 1, 1, 1]}], + }, +) +print(json.loads(bars["content"][0]["text"])) +``` + +--- + +## See also + +- `python -m ferro_ta.mcp` - stdio MCP entrypoint +- `ferro_ta.mcp.create_server()` - FastMCP server factory +- `ferro_ta.tools.api_info` - API discovery helpers used by the MCP catalog +- `ferro_ta.tools` - stable wrappers such as `compute_indicator` +- `docs/agentic.md` - workflow and agent integration notes diff --git a/ferro-ta-main/docs/migration_talib.rst b/ferro-ta-main/docs/migration_talib.rst new file mode 100644 index 0000000..8e9aec5 --- /dev/null +++ b/ferro-ta-main/docs/migration_talib.rst @@ -0,0 +1,168 @@ +Migration from TA-Lib +===================== + +ferro-ta is designed as a drop-in replacement for `ta-lib` (the Python +`talib` package) for the most-commonly used indicators. This guide explains +the differences so you can migrate existing code with confidence. + +.. contents:: + :local: + :depth: 2 + + +Import changes +-------------- + +TA-Lib uses a single flat namespace:: + + import talib + result = talib.SMA(close, timeperiod=14) + +ferro-ta exposes the same names at the top level **and** in sub-modules:: + + # Option A — top-level (most concise, mirrors talib) + from ferro_ta import SMA, EMA, RSI + result = SMA(close, timeperiod=14) + + # Option B — sub-modules + from ferro_ta.overlap import SMA + from ferro_ta.momentum import RSI + +Multi-output functions return a **tuple** in both libraries:: + + # talib + upper, middle, lower = talib.BBANDS(close) + + # ferro_ta + upper, middle, lower = ferro_ta.BBANDS(close) + + +Input / output conventions +-------------------------- + +Both libraries accept NumPy ``float64`` arrays. ferro-ta also accepts any +array-like (Python list, ``float32``, pandas Series) and converts +automatically. + +- **Leading NaN values** — both libraries emit ``NaN`` for the "warm-up" + period at the start of an array. The number of ``NaN`` values is identical + for all indicators marked **Exact** or **Close** in the accuracy table. +- **Output length** — always equal to input length, matching TA-Lib. +- **Pandas Series** — ferro-ta transparently preserves the original index when + a ``pd.Series`` is passed as input. + + +Accuracy levels +--------------- + +.. list-table:: + :header-rows: 1 + + * - Symbol + - Meaning + * - ✅ **Exact** + - Values match TA-Lib to floating-point precision. + * - ✅ **Close** + - Values converge to TA-Lib after the warm-up window (EMA-seed + differences resolve within ~50 bars for typical periods). + * - ⚠️ **Corr** + - Strong correlation (> 0.95) but not numerically identical (e.g. + MAMA uses the same algorithm but slightly different initialization). + * - ⚠️ **Shape** + - Same output shape and NaN structure; absolute values differ (e.g. SAR + reversal history can diverge due to floating-point accumulation). + +All overlap, momentum, volume, volatility, statistic, and price-transform +functions are **Exact** or **Close**. The only remaining **Corr / Shape** +functions are MAMA, SAR, SAREXT, and the six HT_* cycle indicators — see +the roadmap for details. + + +Known behavioural differences +------------------------------ + +EMA / DEMA / TEMA / T3 / MACD +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TA-Lib seeds the first EMA value with a simple moving average. ferro-ta uses +the same seeding, so values converge after the warm-up period. For a 14-period +EMA on typical market data, convergence is complete by bar ~60. + +RSI +~~~ + +ferro-ta uses the same Wilder smoothing seed as TA-Lib (SMA seed for the first +``timeperiod`` bars) and produces **Exact** results. + +SAR / SAREXT +~~~~~~~~~~~~ + +Parabolic SAR reversal history can diverge in rare edge-cases due to +floating-point accumulation differences. Output shapes (NaN count, length) +match exactly. + +HT_* cycle indicators +~~~~~~~~~~~~~~~~~~~~~ + +The Hilbert Transform cycle indicators (``HT_DCPERIOD``, ``HT_DCPHASE``, +``HT_PHASOR``, ``HT_SINE``, ``HT_TRENDLINE``, ``HT_TRENDMODE``) use the +same Ehlers algorithm as TA-Lib but may differ slightly in floating-point +accumulation. All six share a 63-bar lookback matching TA-Lib. + +OBV +~~~ + +ferro-ta OBV starts accumulation from zero at bar 0 (same as TA-Lib for most +data sets). If your TA-Lib OBV shows an offset this is usually due to a +starting volume difference in the input data. + + +Before / after example +----------------------- + +.. code-block:: python + + # --- Before (ta-lib) --- + import numpy as np + import talib + + close = np.random.rand(200).cumsum() + 100.0 + high = close + 0.5 + low = close - 0.5 + + sma = talib.SMA(close, timeperiod=14) + ema = talib.EMA(close, timeperiod=14) + rsi = talib.RSI(close, timeperiod=14) + upper, mid, lower = talib.BBANDS(close, timeperiod=20) + macd, signal, hist = talib.MACD(close) + atr = talib.ATR(high, low, close, timeperiod=14) + + # --- After (ferro_ta) --- + import numpy as np + from ferro_ta import SMA, EMA, RSI, BBANDS, MACD, ATR + + close = np.random.rand(200).cumsum() + 100.0 + high = close + 0.5 + low = close - 0.5 + + sma = SMA(close, timeperiod=14) + ema = EMA(close, timeperiod=14) + rsi = RSI(close, timeperiod=14) + upper, mid, lower = BBANDS(close, timeperiod=20) + macd, signal, hist = MACD(close) + atr = ATR(high, low, close, timeperiod=14) + +Only the import line changes for the most common indicators. + + +Extended (non-TA-Lib) indicators +--------------------------------- + +ferro-ta additionally provides indicators not in TA-Lib:: + + from ferro_ta import ( + VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, + KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX, + ) + +See :doc:`extended` for full API documentation. diff --git a/ferro-ta-main/docs/options-volatility.md b/ferro-ta-main/docs/options-volatility.md new file mode 100644 index 0000000..6cdf3b9 --- /dev/null +++ b/ferro-ta-main/docs/options-volatility.md @@ -0,0 +1,79 @@ +# Options and Implied Volatility + +`ferro-ta` exposes options analytics from `ferro_ta.analysis.options`. + +## Scope + +The module now covers both classic IV-series helpers and model-based option +analytics: + +- `iv_rank`, `iv_percentile`, `iv_zscore` +- Black-Scholes-Merton pricing +- Black-76 pricing +- Delta, gamma, vega, theta, rho +- Implied volatility inversion +- Smile metrics and chain helpers + +Heavy computation runs in Rust through the `_ferro_ta` extension. + +## IV-series helpers + +The original rolling helpers remain available and keep their public names: + +```python +import numpy as np +from ferro_ta.analysis.options import iv_rank, iv_percentile, iv_zscore + +iv = np.array([18.5, 22.3, 19.1, 25.0, 30.2, 27.8, 21.4, 19.0]) +rank = iv_rank(iv, window=5) +pct = iv_percentile(iv, window=5) +z = iv_zscore(iv, window=5) +``` + +These helpers accept a 1-D IV series and return rolling statistics with +`NaN` during the warmup period. + +## Pricing and Greeks + +```python +from ferro_ta.analysis.options import greeks, implied_volatility, option_price + +price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call") +g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +``` + +Conventions: + +- Volatility is decimal annualized volatility: `0.20` means 20%. +- Rates are decimal annualized rates: `0.05` means 5%. +- `time_to_expiry` is measured in years. +- `model="bsm"` uses spot as the underlying input. +- `model="black76"` uses forward as the underlying input. + +## Smile and chain helpers + +```python +from ferro_ta.analysis.options import label_moneyness, select_strike, smile_metrics + +strikes = [80, 90, 100, 110, 120] +vols = [0.30, 0.25, 0.20, 0.22, 0.27] + +metrics = smile_metrics(strikes, vols, 100.0, 0.5) +labels = label_moneyness(strikes, 100.0, option_type="call") +atm = select_strike(strikes, 100.0, selector="ATM") +delta_strike = select_strike( + strikes, + 100.0, + selector="DELTA0.25", + option_type="call", + volatilities=vols, + time_to_expiry=0.5, +) +``` + +## Related futures analytics + +See `ferro_ta.analysis.futures` and +[`docs/derivatives-analytics.md`](./derivatives-analytics.md) for synthetic +forwards, basis, carry, curve, and roll analytics. diff --git a/ferro-ta-main/docs/out-of-core.md b/ferro-ta-main/docs/out-of-core.md new file mode 100644 index 0000000..9999ac9 --- /dev/null +++ b/ferro-ta-main/docs/out-of-core.md @@ -0,0 +1,169 @@ +# Out-of-Core and Distributed Execution + +ferro-ta is designed to work efficiently on large datasets that do not fit +in memory by supporting **chunked execution** with warm-up overlap. This +document explains the problem, the recommended approach, and current +limitations. + +--- + +## Problem statement + +Technical analysis indicators are typically stateful: they require a +look-back window of historical bars to produce a valid value. When a price +dataset is larger than available memory (e.g. tick data, multiple years of +1-second bars), or when it needs to be processed in a distributed cluster +(Spark, Dask), the data must be split into chunks. + +The challenges are: + +1. **Warm-up / border effects** — the first `period - 1` bars of each chunk + will produce NaN because the indicator has not yet accumulated enough + history. +2. **Partition stitching** — after computing an indicator on each partition + independently, the partial results must be assembled into a single + coherent output. +3. **Indicators that need full history** — some indicators (e.g. Hilbert + Transform cycle indicators) cannot be decomposed into partitions; they + require the full series. + +--- + +## Chunk boundaries and warm-up overlap + +The `ferro_ta.chunked` module provides Rust-backed helpers for chunk-based +execution: + +```python +from ferro_ta.chunked import make_chunk_ranges, trim_overlap, stitch_chunks, chunk_apply +from ferro_ta import SMA + +import numpy as np + +data = np.random.rand(1_000_000) # large price series +period = 20 +overlap = period - 1 # warm-up bars needed + +ranges = make_chunk_ranges(len(data), chunk_size=50_000, overlap=overlap) +chunks_out = [] +for start, end in ranges: + chunk = data[start:end] + out = SMA(chunk, timeperiod=period) + chunks_out.append(out) + +result = stitch_chunks(chunks_out, overlap=overlap) +``` + +### Key concepts + +| Concept | Description | +|---------|-------------| +| `chunk_size` | Number of bars per chunk (excluding overlap). | +| `overlap` | Warm-up bars prepended to each chunk from the previous chunk. | +| `trim_overlap` | Strips the warm-up prefix from a chunk result. | +| `stitch_chunks` | Concatenates trimmed chunk results into the final output. | +| `chunk_apply` | Convenience wrapper: runs a callable on each chunk and stitches. | + +--- + +## Options for distributed / out-of-core execution + +### Option A: Chunked pandas with overlap (single-machine, recommended) + +Use `chunk_apply` or `make_chunk_ranges` + manual loop. Suitable for +datasets up to ~10 GB that fit on a single machine with streaming reads. + +```python +from ferro_ta.chunked import chunk_apply +from ferro_ta import EMA + +result = chunk_apply(data, EMA, chunk_size=100_000, overlap=50, timeperiod=50) +``` + +### Option B: Dask `map_partitions` (distributed) + +Dask can partition a large array and apply a function to each partition. +To handle warm-up correctly, use overlapping partitions via +`dask.array.overlap.overlap`: + +```python +import dask.array as da +from dask.array.overlap import overlap as da_overlap +from ferro_ta import SMA + +x = da.from_array(price_array, chunks=100_000) +depth = 20 - 1 # warm-up depth + +x_ov = da_overlap(x, depth={0: depth}, boundary={0: "none"}) +result = x_ov.map_blocks(lambda blk: SMA(blk, timeperiod=20)) +# trim overlap from each block +result_trimmed = da.map_blocks( + lambda blk: blk[depth:], + result, + dtype=float, +) +``` + +### Option C: Apache Spark (brief) + +Spark does not natively support overlapping windows for time-series +indicators. You would need to: + +1. Repartition data by time range with explicit padding. +2. Apply the indicator via a Pandas UDF. +3. Filter out warm-up rows in a post-processing step. + +This approach is feasible but complex. For most use-cases, Dask +(Option B) is simpler. + +--- + +## Recommended path + +| Scale | Recommendation | +|-------|---------------| +| Single machine, fits in RAM | Use ferro_ta directly on the full array. | +| Single machine, does not fit in RAM | `chunk_apply` with overlap (Option A). | +| Multi-machine cluster | Dask `map_partitions` with `dask.array.overlap` (Option B). | + +--- + +## Which indicators are safe for partition-wise execution + +Indicators that depend only on a fixed-length window are **safe** for +chunked/partition-wise execution (with correct overlap): + +- All overlap studies: SMA, EMA, WMA, DEMA, TEMA, BBANDS, etc. +- Momentum: RSI, MACD, STOCH, ADX, CCI, WILLR, etc. +- Volatility: ATR, NATR. +- Most volume indicators: OBV, AD (cumulative; use `stitch_chunks` carefully). + +Indicators that are **not safe** for partition-wise execution without +special handling: + +- Hilbert Transform cycle indicators (`HT_*`) — require full history. +- Adaptive indicators with unbounded look-back (e.g. KAMA with long + adaptation period). +- Streaming state-machine indicators when state must be preserved across + chunks (use `ferro_ta.streaming` classes instead). + +--- + +## Limitations + +- **Volume-weighted indicators** (e.g. VWAP, OBV) accumulate across all + bars; resetting at chunk boundaries changes their semantics. Use + `streaming.StreamingVWAP` for bar-by-bar accumulation instead. +- **SAR and MAMA** have path-dependent state; chunk results will differ + from full-series results unless the prior state is passed across chunks. +- Current `chunk_apply` does not propagate indicator state across chunks; + all indicators restart at each chunk boundary (modulo the overlap + warm-up). + +--- + +## See also + +- `ferro_ta.chunked` — API reference for chunk helpers. +- `ferro_ta.streaming` — Stateful streaming classes for live bar-by-bar use. +- Dask documentation: diff --git a/ferro-ta-main/docs/pandas_api.rst b/ferro-ta-main/docs/pandas_api.rst new file mode 100644 index 0000000..0d731dc --- /dev/null +++ b/ferro-ta-main/docs/pandas_api.rst @@ -0,0 +1,46 @@ +Pandas API contract +=================== + +**Contract** + +- All indicators accept ``pandas.Series`` (or 1-D DataFrame columns) and return + ``pandas.Series`` — or a **tuple of Series** for multi-output functions (e.g. MACD, BBANDS) + — with the **original index preserved**. +- Default OHLCV column names for DataFrames are ``open``, ``high``, ``low``, ``close``, ``volume``. +- To use different column names, use :func:`ferro_ta.utils.get_ohlcv` to extract arrays/Series + with configurable column names, then call the indicator. + +**Single Series** + +.. code-block:: python + + import pandas as pd + from ferro_ta import SMA, RSI + close = pd.Series([44.34, 44.09, 44.15], index=pd.date_range("2024-01-01", periods=3)) + sma = SMA(close, timeperiod=2) # returns pd.Series with same index + +**DataFrame with OHLCV (configurable column names)** + +.. code-block:: python + + import pandas as pd + from ferro_ta import ATR, RSI + from ferro_ta.utils import get_ohlcv + + df = pd.DataFrame({ + "Open": [1, 2, 3], "High": [1.1, 2.1, 3.1], + "Low": [0.9, 1.9, 2.9], "Close": [1.05, 2.05, 3.05], + }, index=pd.date_range("2024-01-01", periods=3, freq="D")) + + o, h, l, c, v = get_ohlcv(df, open_col="Open", high_col="High", + low_col="Low", close_col="Close", volume_col=None) + atr = ATR(h, l, c, timeperiod=2) # index preserved + rsi = RSI(c, timeperiod=2) # index preserved + +**Multi-output** + +Functions like ``MACD`` and ``BBANDS`` return a tuple of ``pandas.Series``, all with the same index as the input. + +**See also** + +- :mod:`ferro_ta.utils` — :func:`get_ohlcv` for DataFrame OHLCV extraction. diff --git a/ferro-ta-main/docs/performance.md b/ferro-ta-main/docs/performance.md new file mode 100644 index 0000000..f23bc0d --- /dev/null +++ b/ferro-ta-main/docs/performance.md @@ -0,0 +1,397 @@ +# Performance Guide + +This document explains the performance characteristics of **ferro-ta** and gives +practical advice on how to get the best speed from the library. + +--- + +## Quick Summary + +| Use case | Recommended API | Notes | +|---------------------------------------|----------------------------------------|----------------------------------------| +| Fast path — NumPy arrays | Pass `np.ndarray` (float64, C-order) | Zero overhead; no conversion needed | +| pandas users | Pass `pd.Series`; result is `pd.Series`| Small overhead for index wrapping | +| polars users | Pass `pl.Series`; result is `pl.Series`| Small overhead for type conversion | +| Raw Rust access (expert) | `from ferro_ta._ferro_ta import sma` | Bypasses all Python wrappers | +| Multiple series at once | `batch_sma`, `batch_ema`, `batch_rsi` | One Python call for all columns | +| Many indicators on same arrays | `compute_many` | Amortizes Python→Rust overhead | + +**Recorded baseline and roadmap:** Performance roadmap and trade-offs are tracked +in [PERFORMANCE_ROADMAP.md](../PERFORMANCE_ROADMAP.md). For reproducible benchmark +inputs/results and methodology, use [benchmarks/README.md](../benchmarks/README.md) +and regenerate with `python benchmarks/bench_vs_talib.py --json benchmark_vs_talib.json`. + +--- + +## The Rust Core Is Fast; Overhead Is in Python + +The Rust extension (`_ferro_ta`) is compiled with full optimisations and is very +fast. The bottlenecks for most users are in the Python wrapping layer: + +1. **Array conversion** — `_to_f64` converts any array-like to a contiguous + `float64` NumPy array. If your input is already a C-contiguous `float64` + ndarray the fast path returns it without any copy or allocation. + +2. **pandas wrapping** — `pandas_wrap` extracts the NumPy array from a + `pd.Series`, calls the Rust function, and wraps the result back into a + `pd.Series` with the original index. The wrapping itself is cheap but adds + a small constant overhead per call. + +3. **polars wrapping** — `polars_wrap` converts a `pl.Series` to NumPy and back. + The result is now built from the NumPy buffer directly (`pl.Series(name, + np.asarray(result))`), which avoids the O(n) `.tolist()` conversion of + earlier versions. + +4. **Batch / grouped execution** — `batch_sma`/`batch_ema`/`batch_rsi` use + Rust-side batch functions for 2-D input (single GIL release for all + columns). `compute_many(...)` groups supported 1-D indicator bundles into + one Rust call, which helps most on medium-to-large workloads. The generic + `batch_apply` still runs a Python loop over columns; use it only when there + is no dedicated fast path. + +--- + +## The Fast Path: Pass Contiguous float64 NumPy Arrays + +The cheapest way to call any indicator is to pass a C-contiguous `float64` +NumPy array. `_to_f64` detects this case and returns the array as-is: + +```python +import numpy as np +from ferro_ta import SMA + +# Already float64 and C-contiguous — _to_f64 is a no-op (zero copy) +close = np.random.rand(10_000).astype(np.float64) +result = SMA(close, timeperiod=20) +``` + +If your array is in a different dtype or order, `_to_f64` will create a new +array. You can force the fast path once and reuse the result: + +```python +close_f64 = np.ascontiguousarray(close, dtype=np.float64) # one-time conversion +result = SMA(close_f64, timeperiod=20) # no copy inside _to_f64 +``` + +--- + +## Raw Numpy-Only API (No Wrapper Overhead) + +If you want zero Python overhead — no pandas/polars wrapping, no validation — +you can import functions directly from the compiled extension: + +```python +from ferro_ta._ferro_ta import sma, ema, rsi # raw Rust functions + +import numpy as np +close = np.random.rand(10_000).astype(np.float64) +result = sma(close, 20) # returns a NumPy array (PyArray1 from PyO3) +``` + +> **Warning:** The raw `_ferro_ta` API is internal and may change between +> versions. It does *not* validate inputs — passing an empty array or a wrong +> type will raise an obscure error from PyO3. Use it only if you have +> profiled a bottleneck and need the absolute minimum overhead. + +For a stable raw API with the same functions, use the `ferro_ta.raw` submodule +(no pandas/polars wrapping or validation). + +--- + +## pandas Series + +```python +import pandas as pd +from ferro_ta import SMA + +s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], index=pd.date_range("2024-01-01", periods=5)) +result = SMA(s, timeperiod=3) +# result is a pd.Series with the same DatetimeIndex +``` + +Overhead compared to a raw numpy call: one `pd.Series.to_numpy()` call (cheap) +plus one `pd.Series(result, index=...)` call (cheap). For large arrays this +is negligible; for very tight loops (millions of calls per second) prefer numpy. + +--- + +## polars Series + +```python +import polars as pl +from ferro_ta import SMA + +s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0]) +result = SMA(s, timeperiod=3) +# result is a pl.Series named "close" +``` + +Overhead: one `.cast(Float64).to_numpy()` call plus one `pl.Series(name, +np.asarray(result))` call. The result is built from the numpy buffer +(zero-copy where polars allows it) rather than going through `.tolist()`. + +--- + +## Batch Execution + +Use the batch API when you have many series (e.g., one column per symbol): + +```python +import numpy as np +from ferro_ta.batch import batch_sma, batch_ema, batch_rsi, batch_apply + +data = np.random.rand(252, 500).astype(np.float64) # 252 bars × 500 symbols +sma_out = batch_sma(data, timeperiod=20) # shape (252, 500) +rsi_out = batch_rsi(data, timeperiod=14) +``` + +`batch_apply` lets you run any indicator on a 2-D array: + +```python +from ferro_ta import ATR +from ferro_ta.batch import batch_apply + +ohlcv = np.random.rand(252, 100, 3).astype(np.float64) # not directly supported +# For indicators that take multiple arrays use a manual loop instead +``` + +For 2-D input, `batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch +functions (single GIL release for all columns). Use `batch_apply` for other +indicators that do not have a dedicated Rust batch implementation. + +When you have several indicators over the same 1-D arrays, use `compute_many`: + +```python +from ferro_ta.batch import compute_many + +results = compute_many( + [ + ("SMA", {"timeperiod": 10}), + ("EMA", {"timeperiod": 12}), + ("RSI", {"timeperiod": 14}), + ], + close=close, +) +``` + +Supported grouped paths currently cover common close-only indicators plus a +small HLC bundle (`ATR`, `NATR`, `ADX`, `ADXR`, `CCI`, `WILLR`). Unsupported +parameter shapes fall back to the normal registry path automatically. + +--- + +## Streaming (Bar-by-Bar) + +```python +from ferro_ta.streaming import StreamingSMA + +sma = StreamingSMA(period=20) +for bar in live_feed: + value = sma.update(bar.close) + if value is not None: + print(f"SMA(20) = {value:.4f}") +``` + +The streaming classes are implemented in Rust (PyO3 `#[pyclass]` in +`_ferro_ta`) and re-exported from `ferro_ta.streaming`. They are suitable for +live trading at typical bar rates with minimal Python overhead. + +--- + +## Extended Indicators + +`VWAP`, `SUPERTREND`, `ICHIMOKU`, `DONCHIAN`, `PIVOT_POINTS`, `KELTNER_CHANNELS`, +`HULL_MA`, `CHANDELIER_EXIT`, `VWMA`, and `CHOPPINESS_INDEX` are implemented in +Rust (`src/extended/mod.rs`). The Python module `ferro_ta/extended.py` is a thin +wrapper with validation and `_to_f64`; all computation runs in the extension. + +--- + +## Tips for Best Performance + +1. **Pre-convert once.** If you call multiple indicators on the same array, + convert it to `float64` + C-contiguous once: + ```python + close = np.ascontiguousarray(raw_close, dtype=np.float64) + ``` + +2. **Avoid repeated dtype conversions.** Passing a `float32` or `int` array + triggers a copy every call. + +3. **Use batch functions for multiple symbols.** For SMA, EMA, and RSI use + `batch_sma`/`batch_ema`/`batch_rsi` (Rust-side loop, single GIL release). + The generic `batch_apply` runs a Python loop over columns; use it only for + indicators that do not have a dedicated Rust batch. + +4. **Avoid wrapping in very tight loops.** If you call an indicator millions + of times per second (e.g., in a simulation) use the raw `_ferro_ta` API + and manage conversion yourself. + +5. **Profile before optimising.** Use `cProfile` or `py-spy` to find the + actual bottleneck before assuming a particular layer is slow. + +6. **Use the perf-contract scripts for evidence.** `benchmarks/run_perf_contract.py` + 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 +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` +- `python benchmarks/check_hotspot_regression.py --input runtime_hotspots.json` + +The WASM bindings also ship with a Node benchmark: + +- `cd wasm && wasm-pack build --target nodejs --out-dir pkg` +- `node bench.js --json ../wasm_benchmark.json` + +## SIMD And Build Flags + +Distributable wheels should stay on the portable release profile: + +- `cargo`/`maturin` release build +- `lto = true` +- `codegen-units = 1` +- no architecture-specific `target-cpu=native` in shipped artifacts + +For local source builds, there are two opt-in tuning levers: + +```bash +# Portable SIMD-enabled local build +uv run maturin develop --release --features simd + +# Maximum local tuning for your current machine only +RUSTFLAGS="-C target-cpu=native" uv run maturin develop --release --features simd +``` + +Policy: + +- Ship portable wheels with the default release settings. +- Use `--features simd` for measured local/source wins. +- Reserve `target-cpu=native` for developer workstations or private deploys, + because those binaries are not portable across CPU families. + +--- + +## Performance Improvements (implemented) + +The following improvements are already in place. See +[docs/plans/2026-03-08-production-grade.md](plans/2026-03-08-production-grade.md) +for history and commits. + +| Area | Improvement | Where | +|-------------|----------------------------------------------------------------|-------| +| **Utils** | `_to_f64` fast path: no copy for 1-D C-contiguous float64 | `python/ferro_ta/_utils.py` (lines 34–39) | +| **Utils** | Polars result: `pl.Series(name, result)` from NumPy buffer (no `.tolist()`) | `python/ferro_ta/_utils.py` (e.g. 254–258) | +| **Raw API** | `ferro_ta.raw` — bypass pandas/polars and validation | `python/ferro_ta/raw.py` | +| **Batch** | Rust batch for SMA/EMA/RSI — single GIL release for 2-D | `src/batch/mod.rs`, `python/ferro_ta/batch.py` | +| **Streaming** | All streaming classes in Rust (PyO3) | `src/streaming/mod.rs` | +| **Extended** | All extended indicators (incl. SUPERTREND) in Rust | `src/extended/mod.rs`, `python/ferro_ta/extended.py` wraps Rust | + +--- + +## Known Bottlenecks and Possible Improvements + +Maintainer-facing list of slower paths and optional improvements. Update as +bottlenecks are fixed or deferred. + +**Backtest** (`python/ferro_ta/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). + Use `batch_sma`/`batch_ema`/`batch_rsi` when possible. +- No fast path for already 2-D C-contiguous float64 in batch_sma/ema/rsi + (unlike `_to_f64` for 1-D); could avoid a potential copy. + +**Derivatives analytics** (`python/ferro_ta/analysis/options.py`): +- `iv_rank`, `iv_percentile`, and `iv_zscore` now delegate to Rust. +- The Python layer mostly performs broadcasting and result shaping; the hot + path is in Rust. +- Model-based implied-volatility inversion is much faster now, but still more + expensive than direct pricing or Greeks due to root-finding. + +**Features** (`python/ferro_ta/features.py`): +- `nan_policy="fill"` is vectorized now. +- `feature_matrix(...)` uses `compute_many(...)`, but grouped HLC bundles are + still only near parity on medium workloads and are best on larger arrays. + +**Signals** (`python/ferro_ta/signals.py`): +- `compose(..., method="rank")` now uses a one-call Rust rank-composition + path, but its gains are moderate rather than dramatic. Keep measuring before + treating it as a major optimization lever. + +**Other**: +- **dsl.py**: Some code paths use Python loops over bars. +- **gpu.py**: Fallback SMA/EMA/RSI use Python loops when GPU is not used. +- **tools.py / viz.py**: `.tolist()` for JSON/Plotly; acceptable for I/O. +- **Validation**: `check_equal_length`, `check_timeperiod` run in Python; + cost is small; moving to Rust is deferred (see production-grade plan). +- **pandas_wrap / polars_wrap**: Per-call overhead; use `ferro_ta.raw` when + minimising overhead. + +--- + +## Benchmarking and comparison + +For cross-library speed, run: +`pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json`. + +To convert benchmark JSON into a markdown table: +`python benchmarks/benchmark_table.py`. + +For focused TA-Lib comparison on the same data/parameters, run +`python benchmarks/bench_vs_talib.py` (requires `pip install ta-lib`). +Results are reported as speedup = TA-Lib time / ferro_ta time (values > 1 mean +ferro_ta is faster). Speedup depends on indicator and data size. + +--- + +## Related Documents + +- [`docs/architecture.md`](architecture.md) — how the Rust/Python layers are + organised and how they communicate. +- [`benchmarks/test_speed.py`](../benchmarks/test_speed.py) — + Authoritative cross-library speed benchmarks (pytest-benchmark). +- [`benchmarks/benchmark_table.py`](../benchmarks/benchmark_table.py) — + Render speed tables from `benchmarks/results.json`. +- [`crates/ferro_ta_core/benches/indicators.rs`](../crates/ferro_ta_core/benches/indicators.rs) — + Rust Criterion benchmarks for the pure core (run with `cargo bench -p ferro_ta_core`). +- [`benchmarks/bench_vs_talib.py`](../benchmarks/bench_vs_talib.py) — speed comparison vs + TA-Lib (same data and parameters); run with `python benchmarks/bench_vs_talib.py` (requires + `ta-lib`). See README “Performance vs TA-Lib” for methodology and a comparison table. +- [`benchmarks/check_vs_talib_regression.py`](../benchmarks/check_vs_talib_regression.py) — + CI guardrail script for detecting severe benchmark regressions from JSON artifacts. diff --git a/ferro-ta-main/docs/plugin-catalog.md b/ferro-ta-main/docs/plugin-catalog.md new file mode 100644 index 0000000..f9d3ca6 --- /dev/null +++ b/ferro-ta-main/docs/plugin-catalog.md @@ -0,0 +1,85 @@ +# Plugin Catalog + +A curated list of ferro-ta plugins and community extensions. + +> **Note:** This catalog is community-maintained and provided on a best-effort +> basis. Plugins are not endorsed or audited by the ferro-ta maintainers. +> Verify each plugin before use in production. + +--- + +## How to Add Your Plugin + +1. Verify that your plugin works with the current ferro-ta release. +2. Open a pull request adding a row to the table below. Include: + - **Name**: package name (PyPI or GitHub) + - **Description**: one-line summary of what the plugin adds + - **Install**: `pip install ...` command + - **Link**: GitHub or PyPI URL + +**Listing criteria:** +- Has a README or documentation describing what it does. +- Works with the current or previous minor version of ferro-ta. +- Is publicly available (PyPI or GitHub). + +--- + +## Known Plugins + +| Name | Description | Install | Link | +|------|-------------|---------|------| +| *(none yet — be the first!)* | | | | + +--- + +## Reference Implementation + +The `examples/custom_indicator.py` file in the ferro-ta repository serves as +the canonical reference for building a plugin. See [Writing a plugin](plugins.rst) +for the full guide. + +```python +# Minimal plugin example +from ferro_ta.registry import register +from ferro_ta import RSI, SMA + +def SMOOTH_RSI(close, timeperiod=14, smooth=3): + """Smoothed RSI: RSI of RSI values.""" + return SMA(RSI(close, timeperiod=timeperiod), timeperiod=smooth) + +register("SMOOTH_RSI", SMOOTH_RSI) +``` + +--- + +## Publishing Your Plugin to PyPI + +1. **Implement** your indicator(s) following the [plugin contract](plugins.rst). +2. **Package** with pyproject.toml using the `ferro_ta.plugins` entry point: + +```toml +[project] +name = "ferro-ta-myplugin" +version = "1.0.0" +dependencies = ["ferro_ta>=1.0.0"] + +[project.entry-points."ferro_ta.plugins"] +auto_register = "ferro_ta_myplugin:register_all" +``` + +3. **Publish** to PyPI: + +```bash +pip install build twine +python -m build +twine upload dist/* +``` + +4. **Submit a PR** to add your plugin to this catalog. + +--- + +## Removal Requests + +If you are the maintainer of a listed plugin and want it removed, open a +GitHub issue with the title "Plugin catalog removal: ". diff --git a/ferro-ta-main/docs/plugins.rst b/ferro-ta-main/docs/plugins.rst new file mode 100644 index 0000000..055f3bf --- /dev/null +++ b/ferro-ta-main/docs/plugins.rst @@ -0,0 +1,91 @@ +Writing a plugin +================ + +The plugin registry lets you register custom indicator functions and call them by name +alongside built-in indicators. This page describes the **plugin contract**, how to +register and run plugins, and a full example. + +Plugin contract +--------------- + +A plugin is a **callable** (function or callable object) that satisfies: + +1. **Signature** + - At least one positional argument that is array-like (e.g. ``close``, ``high``, ``low``). + - Optional ``*args`` and ``**kwargs`` for parameters (e.g. ``timeperiod=14``). + - :func:`ferro_ta.registry.run` forwards all ``*args`` and ``**kwargs`` to the callable. + +2. **Return type** + - A single ``numpy.ndarray``, or + - A tuple of ``numpy.ndarray`` (for multi-output indicators). + - Output length should match input length (same number of bars); document any exception. + +3. **Behaviour** + - The callable may use ``ferro_ta`` internally (e.g. call :func:`ferro_ta.RSI` and then apply another transformation). + - Plugins run with the caller's privileges; there is no sandboxing. + +Validation +---------- + +:func:`ferro_ta.registry.register` checks that the provided object is callable. If not, +it raises ``TypeError``. No strict signature check is performed at registration time +so that valid plugins (e.g. with default arguments) are not rejected. + +Step-by-step +------------ + +1. **Write a function** that accepts at least one array-like and returns one or more + arrays of the same length as the first argument. + +2. **Register it** with :func:`ferro_ta.registry.register`: + + .. code-block:: python + + from ferro_ta.registry import register + register("MY_INDICATOR", my_indicator_function) + +3. **Call it by name** with :func:`ferro_ta.registry.run`: + + .. code-block:: python + + from ferro_ta.registry import run + result = run("MY_INDICATOR", close, timeperiod=14) + +4. **List all indicators** (built-in and registered) with :func:`ferro_ta.registry.list_indicators`. + +Full example +------------ + +The following plugin computes a smoothed RSI (RSI of RSI, or "double RSI") and is +included in the repo as ``examples/custom_indicator.py``: + +.. code-block:: python + + """Example plugin: smoothed RSI (RSI applied to RSI values).""" + import numpy as np + from ferro_ta.registry import register, run, list_indicators + from ferro_ta import RSI, SMA + + def SMOOTH_RSI(close, timeperiod=14, smooth=3): + """Smoothed RSI: RSI then SMA of the RSI series.""" + rsi = RSI(close, timeperiod=timeperiod) + return SMA(rsi, timeperiod=smooth) + + if __name__ == "__main__": + register("SMOOTH_RSI", SMOOTH_RSI) + close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 44.61, 44.33]) + out = run("SMOOTH_RSI", close, timeperiod=5, smooth=2) + print("SMOOTH_RSI:", out) + assert "SMOOTH_RSI" in list_indicators() + +API reference +------------- + +- :func:`ferro_ta.registry.register` — Register a callable under a name. +- :func:`ferro_ta.registry.unregister` — Remove a registered indicator. +- :func:`ferro_ta.registry.get` — Return the callable for a name. +- :func:`ferro_ta.registry.run` — Look up by name and call with given args/kwargs. +- :func:`ferro_ta.registry.list_indicators` — Sorted list of all registered names. +- :exc:`ferro_ta.registry.FerroTARegistryError` — Raised when a name is not found. + +See :mod:`ferro_ta.registry` for full docstrings. diff --git a/ferro-ta-main/docs/quickstart.rst b/ferro-ta-main/docs/quickstart.rst new file mode 100644 index 0000000..04d9d00 --- /dev/null +++ b/ferro-ta-main/docs/quickstart.rst @@ -0,0 +1,119 @@ +Quick Start +=========== + +Installation +------------ + +.. code-block:: bash + + pip install ferro-ta + + # For Pandas support: + pip install ferro-ta pandas + + # For benchmarks: + pip install ferro-ta pytest-benchmark + +Basic Usage +----------- + +All functions accept NumPy arrays and return NumPy arrays: + +.. code-block:: python + + import numpy as np + from ferro_ta import SMA, EMA, RSI, MACD, BBANDS, ATR + + close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33]) + high = close + 0.5 + low = close - 0.5 + + # Single output + sma = SMA(close, timeperiod=5) + ema = EMA(close, timeperiod=5) + rsi = RSI(close, timeperiod=5) + atr = ATR(high, low, close, timeperiod=5) + + # Multi output + upper, middle, lower = BBANDS(close, timeperiod=5) + macd_line, signal, histogram = MACD(close) + +Pandas Integration +------------------ + +All functions transparently accept ``pandas.Series`` and preserve the index: + +.. code-block:: python + + import pandas as pd + from ferro_ta import SMA, BBANDS + + idx = pd.date_range("2024-01-01", periods=10, freq="D") + close = pd.Series([44.34, 44.09, 44.15, 43.61, 44.33, + 44.83, 45.10, 45.15, 43.61, 44.33], index=idx) + + sma = SMA(close, timeperiod=3) # → pd.Series, same index + upper, mid, lower = BBANDS(close, timeperiod=3) # → tuple of pd.Series + +Streaming / Live Trading +------------------------ + +Use the :mod:`ferro_ta.streaming` module for bar-by-bar processing: + +.. code-block:: python + + from ferro_ta.streaming import StreamingSMA, StreamingRSI, StreamingATR + + sma = StreamingSMA(period=5) + rsi = StreamingRSI(period=14) + atr = StreamingATR(period=14) + + for bar in live_feed: + current_sma = sma.update(bar.close) + current_rsi = rsi.update(bar.close) + current_atr = atr.update(bar.high, bar.low, bar.close) + +Extended Indicators +------------------- + +.. code-block:: python + + from ferro_ta import VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS + import numpy as np + + high = np.array([...]) + low = np.array([...]) + close = np.array([...]) + vol = np.array([...]) + + # VWAP + vwap = VWAP(high, low, close, vol) + rolling_vwap = VWAP(high, low, close, vol, timeperiod=14) + + # Supertrend + st_line, direction = SUPERTREND(high, low, close, timeperiod=7, multiplier=3.0) + + # Ichimoku Cloud + tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(high, low, close) + + # Donchian Channels + dc_upper, dc_mid, dc_lower = DONCHIAN(high, low, timeperiod=20) + + # Pivot Points + pivot, r1, s1, r2, s2 = PIVOT_POINTS(high, low, close, method="classic") + +Derivatives Analytics +--------------------- + +.. code-block:: python + + from ferro_ta.analysis.options import greeks, option_price + from ferro_ta.analysis.futures import basis + + call_price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") + call_greeks = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") + front_basis = basis(100.0, 103.0) + +See :doc:`derivatives` for the full analytics surface, including implied +volatility inversion, smile metrics, strike selection, futures curve tools, +strategy schemas, and multi-leg payoff helpers. diff --git a/ferro-ta-main/docs/rust_first.md b/ferro-ta-main/docs/rust_first.md new file mode 100644 index 0000000..7b99132 --- /dev/null +++ b/ferro-ta-main/docs/rust_first.md @@ -0,0 +1,213 @@ +# Rust-First Architecture Policy + +> **Rule:** All non-trivial computation and processing logic MUST be +> implemented in Rust and exposed to Python via PyO3. Python is the +> **interface layer** only. + +--- + +## Rationale + +ferro-ta is built on the insight that Python is excellent as a glue layer +(validation, type dispatch, pandas/polars wrapping) but poor as a compute +engine (GIL, interpreter overhead, per-call allocation). Every Python loop +over data is a performance regression. + +This policy formalises what the codebase already does for standard TA-Lib +indicators and extends it to all new and existing indicators. + +--- + +## The Boundary + +``` +Python layer (thin) Rust layer (thick) +───────────────────────────── ──────────────────────────────────── +ferro_ta/overlap.py ────▶ src/overlap/mod.rs +ferro_ta/momentum.py ────▶ src/momentum/mod.rs +ferro_ta/streaming.py ────▶ src/streaming/mod.rs (PyO3 classes) +ferro_ta/extended.py ────▶ src/extended/mod.rs +ferro_ta/math_ops.py ────▶ src/math_ops/mod.rs +ferro_ta/batch.py ────▶ src/batch/mod.rs +ferro_ta/pattern.py ────▶ src/pattern/mod.rs +... ────▶ ... +``` + +**Python layer responsibilities (ONLY):** +- Input validation (`check_equal_length`, `check_timeperiod`) +- `_to_f64()` conversion (already has fast path for contiguous float64) +- pandas/polars wrapping (via `pandas_wrap` / `polars_wrap` decorators) +- Re-exporting and documentation + +**Rust layer responsibilities (EVERYTHING ELSE):** +- All loops over data +- All rolling window computations +- All stateful streaming state machines +- All mathematical transformations applied bar-by-bar +- All batch operations + +--- + +## Implementation Rules + +### Rule 1: New indicators go in Rust first + +When adding a new indicator: + +1. Implement the algorithm in `src//mod.rs` (or a new category + module if the category does not exist). +2. Register the function in `src/lib.rs` via `::register(m)?`. +3. Write a thin Python wrapper in `python/ferro_ta/.py` that: + - Validates inputs + - Calls `_to_f64()` on array arguments + - Calls the Rust function + - Wraps the result for pandas/polars if the output is a `np.ndarray` +4. Export from `python/ferro_ta/__init__.py` via the usual `__all__` + + `pandas_wrap` / `polars_wrap` pattern. + +**Do not write the algorithm in Python first and port it later.** Porting is +expensive; getting it right in Rust first is cheaper. + +### Rule 2: Porting Python algorithms to Rust + +If you find a Python loop that iterates over data (e.g., `for i in range(n):`) +or a pure-Python rolling window computation, it is a porting candidate. +Priority order: +1. Hot paths called from batch or streaming contexts. +2. Any loop where `n` can be 10,000+. +3. Loops inside extended indicators. + +When porting: +- The Python function becomes a thin wrapper that calls the Rust function. +- There is no Python fallback; the extension must be built. If the Rust call + fails, the function is allowed to fail (no silent fallback to Python). + +### Rule 3: No raw NumPy loops in indicator logic + +The following patterns are **forbidden** in indicator implementation code: + +```python +# ❌ Forbidden: Python loop over data +for i in range(n): + result[i] = compute(data[i - period : i]) + +# ❌ Forbidden: nested Python loop in rolling window +for i in range(timeperiod - 1, n): + result[i] = data[i + 1 - timeperiod : i + 1].max() +``` + +The following are **allowed** in Python wrappers only: +```python +# ✓ Allowed: vectorised NumPy (no loop) +result = np.cumsum(data) + +# ✓ Allowed: scalar operations (no loop over n) +tp = (high + low + close) / 3.0 +``` + +### Rule 4: Streaming classes are Rust PyO3 classes + +Streaming (bar-by-bar stateful) classes **must** be `#[pyclass]` types +implemented in `src/streaming/mod.rs`. Python should import and re-export +them — never re-implement them. + +Template for a new streaming class: +```rust +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingMyIndicator { + period: usize, + // ... state fields +} + +#[pymethods] +impl StreamingMyIndicator { + #[new] + pub fn new(period: usize) -> PyResult { ... } + pub fn update(&mut self, value: f64) -> f64 { ... } + pub fn reset(&mut self) { ... } + #[getter] + pub fn period(&self) -> usize { self.period } +} +``` + +Then in `src/streaming/mod.rs::register()`: +```rust +m.add_class::()?; +``` + +And in `python/ferro_ta/streaming.py`: +```python +from ferro_ta._ferro_ta import StreamingMyIndicator # noqa: F401 +``` + +### Rule 5: Batch operations are Rust functions + +Batch functions that process multiple time-series at once must be implemented +in `src/batch/mod.rs`. They accept 2-D numpy arrays and loop over columns +entirely in Rust (one GIL release covers all columns). + +### Rule 6: Document the Rust location + +Every Python wrapper docstring must note that the algorithm is in Rust: + +```python +def MY_INDICATOR(close, timeperiod=14): + """My Indicator. + ... + Notes + ----- + Implemented in Rust — see ``src/my_category/my_indicator.rs``. + """ +``` + +--- + +## What Belongs in Python Only + +Some things are **intentionally** in Python and should stay there: + +| Thing | Why it stays in Python | +|---|---| +| `pandas_wrap` / `polars_wrap` decorators | Pandas/polars are Python libraries; zero-copy Rust wrappers are not practical here | +| `_to_f64` fast path check | One Python branch beats a PyO3 round-trip for the already-valid case | +| `check_equal_length`, `check_timeperiod` | Negligible overhead vs indicator computation; keeps Rust functions focused | +| `Pipeline`, `Config` | Orchestration logic — Python is appropriate | +| `gpu.py` (CuPy PoC) | CuPy is Python-native; Rust cannot talk to GPU without CUDA bindings | +| `backtest.py` helpers | High-level orchestration | + +--- + +## Current Status (as of 2026-03-08) + +| Module | Logic location | +|---|---| +| `overlap.py` | ✅ Rust (`src/overlap/`) | +| `momentum.py` | ✅ Rust (`src/momentum/`) | +| `volatility.py` | ✅ Rust (`src/volatility/`) | +| `statistic.py` | ✅ Rust (`src/statistic/`) | +| `volume.py` | ✅ Rust (`src/volume/`) | +| `price_transform.py` | ✅ Rust (`src/price_transform/`) | +| `pattern.py` | ✅ Rust (`src/pattern/`) | +| `cycle.py` | ✅ Rust (`src/cycle/`) | +| `batch.py` | ✅ Rust (`src/batch/`) | +| `streaming.py` | ✅ Rust (`src/streaming/`) — all 9 classes | +| `extended.py` | ✅ Rust (`src/extended/`) — all 10 indicators | +| `math_ops.py` (rolling) | ✅ Rust (`src/math_ops/`) — SUM/MAX/MIN/MAXINDEX/MININDEX | +| `math_ops.py` (element-wise) | ✅ NumPy wrappers (no loops — vectorised by NumPy's C core) | +| `gpu.py` | ⚠️ CuPy (Python/CUDA — intentional, see above) | +| `pipeline.py` | ✅ Orchestration only (no indicator loops) | +| `config.py` | ✅ Configuration only | +| `backtest.py` | ✅ Orchestration only | + +--- + +## Checklist for New Indicator PRs + +- [ ] Algorithm implemented in `src//mod.rs` +- [ ] `cargo fmt --check` passes +- [ ] `cargo clippy --release -- -D warnings` passes +- [ ] Python wrapper is **thin** (validation + `_to_f64` + Rust call) +- [ ] No Python loops over data +- [ ] Docstring notes "Implemented in Rust" +- [ ] Registered in `src/lib.rs` and exported from `__init__.py` +- [ ] Tests added in `tests/` diff --git a/ferro-ta-main/docs/stability.md b/ferro-ta-main/docs/stability.md new file mode 100644 index 0000000..9a21f15 --- /dev/null +++ b/ferro-ta-main/docs/stability.md @@ -0,0 +1,104 @@ +# API Stability Policy + +This document describes which parts of **ferro-ta** are considered stable, which +are experimental, and what the deprecation process is. + +--- + +## Stability Tiers + +### Stable + +The following are considered **stable** and will not change in incompatible ways +without a major version bump (i.e., following [Semantic Versioning 2.0.0]): + +- All indicator functions exported from `ferro_ta.*` by name (e.g. `ferro_ta.SMA`, + `ferro_ta.RSI`, `ferro_ta.BBANDS`). +- Sub-module imports: `from ferro_ta.overlap import SMA` etc. +- Function signatures: positional array arguments and `timeperiod` / other keyword + arguments documented in the docstrings. +- Return types: single `np.ndarray` or tuple of `np.ndarray` as documented. +- Exception classes: `FerroTAError`, `FerroTAValueError`, `FerroTAInputError`. +- Utility helpers: `ferro_ta.utils.get_ohlcv`, `ferro_ta._utils.get_ohlcv`. +- `pandas_wrap` / `polars_wrap` behaviour: `pd.Series` in → `pd.Series` out; + `pl.Series` in → `pl.Series` out. +- Registry API: `ferro_ta.registry.register`, `run`, `get`, `list_indicators`. +- Pipeline API: `ferro_ta.pipeline.Pipeline`, `make_pipeline`. +- Config API: `ferro_ta.config.set_default`, `ferro_ta.config.Config`. + +### Experimental + +The following are **experimental** and may change in minor releases: + +- **`ferro_ta.raw`** — direct access to the compiled Rust extension; function + signatures follow the Rust layer and may change when the Rust layer changes. +- **`ferro_ta.batch`** internals — the Python↔Rust dispatch logic may change as + the Rust batch API evolves. +- **`ferro_ta.streaming`** — the streaming class API (especially the `reset()` + method and internal buffer access) may evolve; the `update()` method signature + is stable. +- **`ferro_ta.extended`** — extended indicators (VWAP, SUPERTREND, etc.) are + considered stable in return shape and semantics, but implementation details + (e.g. whether computation is in Python or Rust) may change. +- **`ferro_ta.backtest`** — the backtest helpers are convenience utilities and + may be refactored. +- **`ferro_ta.gpu`** — the CuPy GPU backend is an experimental proof-of-concept. + +### Internal / Private + +Names prefixed with `_` (e.g. `_ferro_ta`, `_utils`, `_to_f64`) are internal +and may change at any time without notice. Do not rely on them in user code. + +--- + +## Versioning + +ferro-ta follows [Semantic Versioning 2.0.0]: + +| Change type | Version bump | +|----------------------------------------|--------------| +| Breaking API change (removed indicator, renamed parameter, changed return type) | **MAJOR** | +| New indicators, new sub-modules, new features (backward-compatible) | **MINOR** | +| Bug fixes, performance improvements, docs, dependency bumps | **PATCH** | + +The current version (`1.x`) is stable. Breaking changes to stable APIs are +reserved for future **major** releases. + +--- + +## Deprecation Policy + +Before removing or renaming any **stable** API: + +1. The deprecated name/function is kept until the next **major release** after + the deprecation notice. +2. A `DeprecationWarning` is raised when the deprecated API is used. +3. The deprecation and removal are documented in `CHANGELOG.md` under + `### Deprecated` and `### Removed`. + +Example timeline: + +- `1.1.0` — `OLD_NAME` deprecated, `DeprecationWarning` added; `NEW_NAME` available. +- `2.0.0` — `OLD_NAME` removed. + +--- + +## What is NOT covered + +- The Rust ABI of the compiled extension (`_ferro_ta.so` / `_ferro_ta.pyd`). + Only the Python-level API is covered by this policy. +- Numerical precision beyond what is documented (exact TA-Lib matches for listed + indicators, "correlated" for Wilder-seeded indicators). +- Performance characteristics — we may change the implementation to be faster + (e.g. moving a Python loop to Rust) without a version bump. + +--- + +## Requesting Stability Guarantees + +If you depend on an experimental API and would like it promoted to stable, please +open an issue on GitHub explaining your use case. We will consider promoting +experimental APIs to stable when they have been in use long enough to be confident +in their design. + +[Semantic Versioning 2.0.0]: https://semver.org/ diff --git a/ferro-ta-main/docs/streaming.rst b/ferro-ta-main/docs/streaming.rst new file mode 100644 index 0000000..332de5b --- /dev/null +++ b/ferro-ta-main/docs/streaming.rst @@ -0,0 +1,12 @@ +Streaming API +============= + +The :mod:`ferro_ta.streaming` module provides stateful, bar-by-bar indicator computation +for live/real-time trading. Each class maintains an internal buffer and returns ``NaN`` +during the warmup period. + +.. automodule:: ferro_ta.streaming + :no-index: + :members: + :undoc-members: + :show-inheritance: diff --git a/ferro-ta-main/docs/support_matrix.rst b/ferro-ta-main/docs/support_matrix.rst new file mode 100644 index 0000000..da41485 --- /dev/null +++ b/ferro-ta-main/docs/support_matrix.rst @@ -0,0 +1,190 @@ +Support Matrix +============== + +The primary product is the Python technical analysis library: TA-Lib-style +indicator calls backed by a Rust implementation. + +Indicator compatibility +----------------------- + +.. list-table:: + :header-rows: 1 + + * - Status + - Scope + - Notes + * - Exact parity + - Common TA-Lib-compatible indicators such as ``SMA``, ``WMA``, + ``BBANDS``, ``RSI``, ``ATR``, ``NATR``, ``CCI``, ``STOCH``, + ``STOCHRSI``, and most candlestick patterns + - Matches TA-Lib numerically within floating-point tolerance in the + current comparison suite. + * - Approximate parity + - EMA-family indicators (``EMA``, ``DEMA``, ``TEMA``, ``T3``, ``MACD``), + ``MAMA`` / ``FAMA``, ``SAR`` / ``SAREXT``, and ``HT_*`` cycle + indicators + - Same API and intended use, with convergence-window or floating-point + differences documented in the migration guide. + * - Intentionally different + - ferro-ta-only indicators such as ``VWAP``, ``SUPERTREND``, + ``ICHIMOKU``, ``DONCHIAN``, ``KELTNER_CHANNELS``, ``HULL_MA``, + ``CHANDELIER_EXIT``, ``VWMA``, and ``CHOPPINESS_INDEX`` + - These extend the library beyond TA-Lib and are not parity claims. + +For migration details and known indicator-specific differences, see +:doc:`migration_talib`. + +Module status +------------- + +.. list-table:: + :header-rows: 1 + + * - Surface + - Status + - Notes + * - Top-level indicators and category submodules + - Stable core + - This is the main supported surface of the project. + * - ``ferro_ta.batch`` + - Supported + - Public API is supported; internal dispatch may evolve. + * - ``ferro_ta.streaming`` + - Supported, still evolving + - Suitable for live workflows; some API details are still marked + experimental in the stability policy. + * - ``ferro_ta.extended`` + - Supported extension + - Useful indicators beyond TA-Lib, but not part of drop-in parity claims. + * - ``ferro_ta.analysis.*`` + - Adjacent tooling + - Useful analytics helpers, but not the primary product story. + * - ``ferro_ta.analysis.resample`` + - Supported (v1.1.0) + - ``resample_ohlcv()``, ``align_to_coarse()``, ``resample_ohlcv_labels()`` — pure-NumPy + OHLCV bar aggregation across timeframes. + * - ``ferro_ta.analysis.multitf`` + - Supported (v1.1.0) + - ``MultiTimeframeEngine`` — multi-timeframe signal generation with automatic alignment. + * - ``ferro_ta.analysis.adjust`` + - Supported (v1.1.0) + - ``adjust_ohlcv()``, ``adjust_for_splits()``, ``adjust_for_dividends()`` — backward-adjusted + price series for equity/index strategies. + * - ``ferro_ta.analysis.plot`` + - Supported (v1.1.0) + - ``plot_backtest()`` — interactive Plotly backtest visualization (requires plotly). + * - ``ferro_ta.analysis.regime`` + - Supported (v1.1.0) + - ``detect_volatility_regime()``, ``detect_trend_regime()``, ``detect_combined_regime()``, + ``RegimeFilter`` — pure-NumPy 6-state market regime labeling; no ML dependencies. + * - ``ferro_ta.analysis.optimize`` + - Supported (v1.1.0) + - ``PortfolioOptimizer``, ``mean_variance_optimize()``, ``risk_parity_optimize()``, + ``max_sharpe_optimize()`` — portfolio optimization via SLSQP (requires scipy). + * - ``ferro_ta.analysis.live`` + - Supported (v1.1.0) + - ``PaperTrader`` — event-driven paper trading bridge matching backtest logic exactly. + * - MCP, WASM, GPU, plugin, and agent-oriented tooling + - Experimental or adjacent + - Evaluate these independently from the core indicator library. + +Backtesting engine features +--------------------------- + +.. list-table:: + :header-rows: 1 + + * - Feature + - Status + - Notes + * - Flat/proportional commission + - Supported + - Via ``CommissionModel`` presets and ``BacktestEngine.with_commission_model()``. + * - Bid-ask spread model (``spread_bps``) + - Supported (v1.1.0) + - New ``CommissionModel.spread_bps`` field; half-spread deducted per leg. + * - Short borrow cost (``short_borrow_rate_annual``) + - Supported (v1.1.0) + - New ``CommissionModel.short_borrow_rate_annual`` field; accrued per bar for short positions. + * - Trailing stop loss + - Supported + - ``BacktestEngine.with_trailing_stop(pct)`` — intrabar high-water mark tracking. + * - Breakeven stop (``breakeven_pct``) + - Supported (v1.1.0) + - ``BacktestEngine.with_breakeven_stop(pct)`` — moves stop to entry once profit reaches ``pct``. + * - Bracket order priority + - Supported (v1.1.0) + - When both SL and TP are breached on the same bar, the level closer to open fires first. + * - Leverage / margin modeling + - Supported (v1.1.0) + - ``BacktestEngine.with_leverage(margin_ratio, margin_call_pct)`` — tracks margin and + triggers force-close on margin call. + * - Loss circuit breakers + - Supported (v1.1.0) + - ``BacktestEngine.with_loss_limits(daily, total)`` — halts trading on drawdown breach. + * - Portfolio constraints + - Supported (v1.1.0) + - ``BacktestEngine.with_portfolio_constraints(max_asset_weight, max_gross_exposure, + max_net_exposure)`` for multi-asset backtests. + * - Volatility-target position sizing + - Supported + - ``BacktestEngine.with_position_sizing("volatility_target", ...)``. + * - Walk-forward / Monte Carlo + - Supported + - Available via ``BacktestEngine`` higher-level methods. + * - Benchmark comparison + - Supported + - ``BacktestEngine.with_benchmark(close_array)`` — alpha, beta, information ratio. + +Supported Python versions +------------------------- + +.. list-table:: + :header-rows: 1 + + * - Python + - Status + * - 3.13 + - Supported and tested in CI + * - 3.12 + - Supported and tested in CI + * - 3.11 + - Supported and tested in CI + * - 3.10 + - Supported and tested in CI + * - < 3.10 + - Not supported + +Tested wheel targets +-------------------- + +.. list-table:: + :header-rows: 1 + + * - OS + - Architecture + - Wheel status + * - Linux + - ``x86_64`` (manylinux2014 / ``manylinux_2_17``) + - Tested wheel target + * - macOS + - ``universal2`` + - Tested wheel target for Intel and Apple Silicon + * - Windows + - ``x86_64`` + - Tested wheel target + +For source builds, packaging details, and platform notes, see +`PLATFORMS.md `_. + +Release status +-------------- + +These docs track package version ``1.2.0``. + +- Release notes by version: :doc:`changelog` +- Canonical project changelog: `CHANGELOG.md `_ +- Stability policy: `docs/stability.md `_ + +If the package version, docs version, or support matrix disagree, treat that as +a documentation bug. diff --git a/ferro-ta-main/examples/README.md b/ferro-ta-main/examples/README.md new file mode 100644 index 0000000..d71dedb --- /dev/null +++ b/ferro-ta-main/examples/README.md @@ -0,0 +1,31 @@ +# ferro-ta Examples + +Jupyter notebooks demonstrating key ferro-ta features. + +## Notebooks + +| Notebook | Description | +|---|---| +| [`quickstart.ipynb`](quickstart.ipynb) | Core API: moving averages, RSI, MACD, Bollinger Bands, batch API, pipeline, pandas integration | +| [`streaming.ipynb`](streaming.ipynb) | Streaming bar-by-bar API: StreamingSMA, StreamingRSI, StreamingBBands, StreamingMACD, StreamingATR | +| [`backtesting.ipynb`](backtesting.ipynb) | Backtesting harness, indicator pipeline for feature engineering, config defaults | +| [`features_21_30.ipynb`](features_21_30.ipynb) | Multi-timeframe, resampling, portfolio analytics, strategy DSL, feature matrix, viz, adapters | + +## Running the Notebooks + +```bash +# Install dependencies +pip install ferro-ta jupyter numpy + +# Optional: pandas and polars integration +pip install "ferro-ta[pandas]" "ferro_ta[polars]" + +# Start Jupyter +jupyter notebook examples/ +``` + +## Links + +- [ferro-ta README](../README.md) +- [API documentation](../docs/) +- [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/ferro-ta-main/examples/backtesting.ipynb b/ferro-ta-main/examples/backtesting.ipynb new file mode 100644 index 0000000..b0eac7f --- /dev/null +++ b/ferro-ta-main/examples/backtesting.ipynb @@ -0,0 +1,200 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Backtesting with ferro-ta\n", + "\n", + "This notebook demonstrates the minimal backtesting harness and the\n", + "indicator pipeline, together with the configuration defaults API.\n", + "\n", + "Install:\n", + "```bash\n", + "pip install ferro-ta\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ferro_ta.config as config\n", + "import numpy as np\n", + "from ferro_ta.backtest import backtest\n", + "from ferro_ta.pipeline import Pipeline\n", + "\n", + "from ferro_ta import BBANDS, EMA, RSI, SMA\n", + "\n", + "# Synthetic data\n", + "np.random.seed(42)\n", + "n = 300\n", + "close = np.cumprod(1 + np.random.randn(n) * 0.01) * 100\n", + "volume = np.random.randint(1000, 10000, n).astype(float)\n", + "print(f\"Generated {n} bars, final price: {close[-1]:.2f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## RSI 30/70 Strategy" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result = backtest(close, strategy=\"rsi_30_70\", timeperiod=14)\n", + "print(\"Strategy: RSI 30/70\")\n", + "print(f\"Final equity: {result.final_equity:.4f}\")\n", + "print(f\"Number of trades: {result.n_trades}\")\n", + "print(f\"Return: {(result.final_equity - 1.0) * 100:.2f}%\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## SMA Crossover Strategy" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result2 = backtest(close, strategy=\"sma_crossover\", fast=10, slow=30)\n", + "print(\"Strategy: SMA Crossover (10/30)\")\n", + "print(f\"Final equity: {result2.final_equity:.4f}\")\n", + "print(f\"Number of trades: {result2.n_trades}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration Defaults\n", + "\n", + "Set global defaults for indicator parameters to avoid repeating them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Set global defaults\n", + "config.set_default(\"timeperiod\", 20) # global default for all indicators\n", + "config.set_default(\"RSI.timeperiod\", 14) # RSI-specific override\n", + "\n", + "print(\"Current defaults:\", config.list_defaults())\n", + "print(\"RSI defaults:\", config.get_defaults_for(\"RSI\"))\n", + "print(\"SMA defaults:\", config.get_defaults_for(\"SMA\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Context manager for temporary overrides\n", + "with config.Config(timeperiod=5):\n", + " temp_default = config.get_default(\"timeperiod\")\n", + " print(f\"Inside context: timeperiod={temp_default}\")\n", + "\n", + "print(f\"After context: timeperiod={config.get_default('timeperiod')}\") # back to 20\n", + "\n", + "# Clean up\n", + "config.reset()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multi-indicator Pipeline for Feature Engineering" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pipe = (\n", + " Pipeline()\n", + " .add(\"sma_10\", SMA, timeperiod=10)\n", + " .add(\"sma_30\", SMA, timeperiod=30)\n", + " .add(\"ema_10\", EMA, timeperiod=10)\n", + " .add(\"rsi_14\", RSI, timeperiod=14)\n", + " .add(\n", + " \"bb\",\n", + " BBANDS,\n", + " output_keys=[\"bb_upper\", \"bb_mid\", \"bb_lower\"],\n", + " timeperiod=20,\n", + " nbdevup=2.0,\n", + " nbdevdn=2.0,\n", + " )\n", + ")\n", + "\n", + "features = pipe.run(close)\n", + "print(\"Feature columns:\", list(features.keys()))\n", + "\n", + "# Build a simple feature matrix (last 5 complete rows)\n", + "valid_start = 30 # warmup\n", + "feature_matrix = np.column_stack([v[valid_start:] for v in features.values()])\n", + "print(f\"Feature matrix shape: {feature_matrix.shape}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Simple Manual Backtest Using the Pipeline" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Signal: long when RSI < 40 AND close > SMA_30; flat otherwise\n", + "rsi_vals = features[\"rsi_14\"]\n", + "sma30_vals = features[\"sma_30\"]\n", + "\n", + "signal = np.where((rsi_vals < 40) & (close > sma30_vals), 1.0, 0.0)\n", + "position = np.roll(signal, 1) # trade on next bar open\n", + "position[0] = 0.0\n", + "\n", + "returns = np.diff(close) / close[:-1]\n", + "strategy_returns = returns * position[1:]\n", + "\n", + "equity = np.cumprod(1 + strategy_returns)\n", + "print(f\"Final equity: {equity[-1]:.4f}\")\n", + "print(f\"Number of signal bars: {int(signal.sum())}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ferro-ta-main/examples/custom_indicator.py b/ferro-ta-main/examples/custom_indicator.py new file mode 100644 index 0000000..246119d --- /dev/null +++ b/ferro-ta-main/examples/custom_indicator.py @@ -0,0 +1,53 @@ +""" +Example plugin: smoothed RSI (SMA of RSI). + +Run this file to verify the plugin contract: + python examples/custom_indicator.py + +Registers "SMOOTH_RSI" and runs it on sample data. +""" + +from __future__ import annotations + +import numpy as np + +from ferro_ta import RSI, SMA +from ferro_ta.core.registry import list_indicators, register, run + + +def smooth_rsi(close, timeperiod=14, smooth=3): + """Smoothed RSI: RSI then SMA of the RSI series. + + Parameters + ---------- + close : array-like + Close prices. + timeperiod : int + RSI period (default 14). + smooth : int + SMA period applied to RSI (default 3). + + Returns + ------- + numpy.ndarray + Smoothed RSI values; same length as close. + """ + rsi = RSI(close, timeperiod=timeperiod) + return SMA(rsi, timeperiod=smooth) + + +def main(): + register("SMOOTH_RSI", smooth_rsi) + close = np.array( + [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 44.61, 44.33] + ) + out = run("SMOOTH_RSI", close, timeperiod=5, smooth=2) + print("SMOOTH_RSI:", out) + assert "SMOOTH_RSI" in list_indicators(), ( + "SMOOTH_RSI should be in list_indicators()" + ) + print("OK: plugin registered and run successfully.") + + +if __name__ == "__main__": + main() diff --git a/ferro-ta-main/examples/quickstart.ipynb b/ferro-ta-main/examples/quickstart.ipynb new file mode 100644 index 0000000..25f1c5f --- /dev/null +++ b/ferro-ta-main/examples/quickstart.ipynb @@ -0,0 +1,234 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ferro-ta Quick Start\n", + "\n", + "This notebook demonstrates the core ferro-ta API.\n", + "\n", + "Install:\n", + "```bash\n", + "pip install ferro-ta\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from ferro_ta import BBANDS, EMA, MACD, RSI, SMA\n", + "\n", + "# Synthetic OHLCV data\n", + "np.random.seed(42)\n", + "n = 200\n", + "close = np.cumprod(1 + np.random.randn(n) * 0.01) * 100\n", + "high = close * (1 + np.abs(np.random.randn(n)) * 0.005)\n", + "low = close * (1 - np.abs(np.random.randn(n)) * 0.005)\n", + "volume = np.random.randint(1_000, 10_000, n).astype(float)\n", + "\n", + "print(f\"Generated {n} bars\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Moving Averages" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sma_20 = SMA(close, timeperiod=20)\n", + "ema_20 = EMA(close, timeperiod=20)\n", + "\n", + "print(\"SMA(20):\", sma_20[-5:])\n", + "print(\"EMA(20):\", ema_20[-5:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## RSI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "rsi = RSI(close, timeperiod=14)\n", + "print(\"RSI(14):\", rsi[-5:])\n", + "print(f\"RSI range: [{np.nanmin(rsi):.2f}, {np.nanmax(rsi):.2f}]\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## MACD" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "macd_line, signal, hist = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)\n", + "print(\"MACD line: \", macd_line[-5:])\n", + "print(\"Signal: \", signal[-5:])\n", + "print(\"Histogram: \", hist[-5:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Bollinger Bands" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "upper, middle, lower = BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)\n", + "print(\"Upper band: \", upper[-5:])\n", + "print(\"Middle band:\", middle[-5:])\n", + "print(\"Lower band: \", lower[-5:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Batch API — multiple symbols at once" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ferro_ta.batch import batch_rsi, batch_sma\n", + "\n", + "# Simulate 5 symbols\n", + "data = np.random.default_rng(0).random((200, 5)) * 100 + 50\n", + "sma_result = batch_sma(data, timeperiod=20)\n", + "rsi_result = batch_rsi(data, timeperiod=14)\n", + "\n", + "print(\"Batch SMA shape:\", sma_result.shape) # (200, 5)\n", + "print(\"Batch RSI shape:\", rsi_result.shape) # (200, 5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pipeline API — compose multiple indicators" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ferro_ta.pipeline import Pipeline\n", + "\n", + "pipe = (\n", + " Pipeline()\n", + " .add(\"sma_20\", SMA, timeperiod=20)\n", + " .add(\"ema_20\", EMA, timeperiod=20)\n", + " .add(\"rsi_14\", RSI, timeperiod=14)\n", + " .add(\n", + " \"bb\",\n", + " BBANDS,\n", + " output_keys=[\"bb_upper\", \"bb_mid\", \"bb_lower\"],\n", + " timeperiod=20,\n", + " nbdevup=2.0,\n", + " nbdevdn=2.0,\n", + " )\n", + ")\n", + "\n", + "results = pipe.run(close)\n", + "print(\"Pipeline outputs:\", list(results.keys()))\n", + "print(\"SMA last 3:\", results[\"sma_20\"][-3:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pandas Integration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " import pandas as pd\n", + "\n", + " s = pd.Series(close, name=\"close\")\n", + " sma_pd = SMA(s, timeperiod=20)\n", + " print(\"Result type:\", type(sma_pd)) # pandas.Series\n", + " print(\"Index preserved:\", list(sma_pd.index[:3]))\n", + "except ImportError:\n", + " print(\"pandas not installed\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Error Handling" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ferro_ta.exceptions import check_timeperiod\n", + "\n", + "from ferro_ta import FerroTAValueError\n", + "\n", + "try:\n", + " check_timeperiod(0)\n", + "except FerroTAValueError as e:\n", + " print(\"Caught:\", e)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ferro-ta-main/examples/streaming.ipynb b/ferro-ta-main/examples/streaming.ipynb new file mode 100644 index 0000000..faa1d0d --- /dev/null +++ b/ferro-ta-main/examples/streaming.ipynb @@ -0,0 +1,206 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Streaming API — bar-by-bar live trading\n", + "\n", + "The `ferro_ta.streaming` module provides stateful classes that process\n", + "data bar-by-bar, suitable for real-time feeds and live trading.\n", + "\n", + "Install:\n", + "```bash\n", + "pip install ferro-ta\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from ferro_ta.streaming import (\n", + " StreamingATR,\n", + " StreamingBBands,\n", + " StreamingEMA,\n", + " StreamingMACD,\n", + " StreamingRSI,\n", + " StreamingSMA,\n", + ")\n", + "\n", + "# Simulate incoming bars\n", + "np.random.seed(42)\n", + "n = 50\n", + "closes = np.cumprod(1 + np.random.randn(n) * 0.01) * 100\n", + "highs = closes * 1.005\n", + "lows = closes * 0.995\n", + "\n", + "print(f\"Simulated {n} bars\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingSMA and StreamingEMA" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sma = StreamingSMA(period=5)\n", + "ema = StreamingEMA(period=5)\n", + "\n", + "sma_values = [sma.update(c) for c in closes]\n", + "ema_values = [ema.update(c) for c in closes]\n", + "\n", + "print(\n", + " \"SMA last 5:\", [f\"{v:.4f}\" if not np.isnan(v) else \"NaN\" for v in sma_values[-5:]]\n", + ")\n", + "print(\n", + " \"EMA last 5:\", [f\"{v:.4f}\" if not np.isnan(v) else \"NaN\" for v in ema_values[-5:]]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingRSI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "rsi_stream = StreamingRSI(period=14)\n", + "\n", + "rsi_values = [rsi_stream.update(c) for c in closes]\n", + "finite = [(i, v) for i, v in enumerate(rsi_values) if not np.isnan(v)]\n", + "print(f\"First valid RSI at bar {finite[0][0]}: {finite[0][1]:.2f}\")\n", + "print(\"RSI last 3:\", [f\"{v:.2f}\" for _, v in finite[-3:]])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingBBands" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bbands = StreamingBBands(period=20)\n", + "\n", + "bb_results = [bbands.update(c) for c in closes]\n", + "# Each result is (upper, middle, lower) or (nan, nan, nan) during warmup\n", + "valid_bb = [\n", + " (i, u, m, lower) for i, (u, m, lower) in enumerate(bb_results) if not np.isnan(m)\n", + "]\n", + "if valid_bb:\n", + " i, u, m, lower = valid_bb[-1]\n", + " print(f\"Latest Bollinger Bands at bar {i}:\")\n", + " print(f\" Upper: {u:.4f}\")\n", + " print(f\" Middle: {m:.4f}\")\n", + " print(f\" Lower: {lower:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingMACD" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "macd_stream = StreamingMACD(fastperiod=12, slowperiod=26, signalperiod=9)\n", + "\n", + "macd_results = [macd_stream.update(c) for c in closes]\n", + "# Each result is (macd_line, signal, histogram)\n", + "valid_macd = [\n", + " (i, m, s, h) for i, (m, s, h) in enumerate(macd_results) if not np.isnan(m)\n", + "]\n", + "if valid_macd:\n", + " i, m, s, h = valid_macd[-1]\n", + " print(f\"Latest MACD at bar {i}:\")\n", + " print(f\" MACD line: {m:.6f}\")\n", + " print(f\" Signal: {s:.6f}\")\n", + " print(f\" Histogram: {h:.6f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingATR" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "atr_stream = StreamingATR(period=14)\n", + "\n", + "atr_values = [atr_stream.update(h, low, c) for h, low, c in zip(highs, lows, closes)]\n", + "finite_atr = [v for v in atr_values if not np.isnan(v)]\n", + "if finite_atr:\n", + " print(f\"Latest ATR: {finite_atr[-1]:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reset and reuse\n", + "\n", + "All streaming classes support `reset()` to clear internal state and start fresh." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sma.reset()\n", + "print(\"After reset, SMA(5.0):\", sma.update(5.0)) # NaN — warm-up restarted\n", + "sma.update(6.0)\n", + "sma.update(7.0)\n", + "sma.update(8.0)\n", + "print(\"SMA after 4 bars:\", sma.update(9.0)) # 7.0 = mean of [5,6,7,8,9]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ferro-ta-main/fuzz/Cargo.toml b/ferro-ta-main/fuzz/Cargo.toml new file mode 100644 index 0000000..5688b12 --- /dev/null +++ b/ferro-ta-main/fuzz/Cargo.toml @@ -0,0 +1,81 @@ +[package] +name = "ferro_ta_fuzz" +version = "0.0.1" +edition = "2021" +publish = false + +# Exclude from the root workspace so cargo doesn't reject it as an unlisted member +[workspace] + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +ferro_ta_core = { path = "../crates/ferro_ta_core" } + +[[bin]] +name = "fuzz_sma" +path = "fuzz_targets/fuzz_sma.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_rsi" +path = "fuzz_targets/fuzz_rsi.rs" +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/ferro-ta-main/fuzz/fuzz_targets/fuzz_atr.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_atr.rs new file mode 100644 index 0000000..37243ab --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/fuzz/fuzz_targets/fuzz_bbands.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_bbands.rs new file mode 100644 index 0000000..e62b5dc --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/fuzz/fuzz_targets/fuzz_ema.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_ema.rs new file mode 100644 index 0000000..32153d1 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/fuzz/fuzz_targets/fuzz_macd.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_macd.rs new file mode 100644 index 0000000..ee4d0d0 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/fuzz/fuzz_targets/fuzz_mfi.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_mfi.rs new file mode 100644 index 0000000..1ad8cb8 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/fuzz/fuzz_targets/fuzz_rsi.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_rsi.rs new file mode 100644 index 0000000..9d9b705 --- /dev/null +++ b/ferro-ta-main/fuzz/fuzz_targets/fuzz_rsi.rs @@ -0,0 +1,52 @@ +/*! +Fuzz target for `ferro_ta_core::momentum::rsi`. + +Generates arbitrary f64 slices (via raw bytes) and arbitrary timeperiods, +verifying that RSI never panics and that all finite output values lie in +the range [0, 100]. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::momentum; + +fuzz_target!(|data: &[u8]| { + // Need at least 1 byte for timeperiod + 8 bytes for one f64 + if data.len() < 2 { + return; + } + + // Extract timeperiod from first byte (1-64) + let timeperiod = ((data[0] as usize) % 64) + 1; + + // Interpret remaining bytes as f64 values + 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(); + + // Must not panic + let result = momentum::rsi(&close, timeperiod); + + // Result length must match input + assert_eq!(result.len(), close.len(), "RSI output length mismatch"); + + // All finite output values must be in [0, 100] + for (i, &v) in result.iter().enumerate() { + if v.is_finite() { + assert!( + v >= 0.0 && v <= 100.0, + "RSI result[{i}] = {v} is out of [0, 100]" + ); + } + } +}); diff --git a/ferro-ta-main/fuzz/fuzz_targets/fuzz_sma.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_sma.rs new file mode 100644 index 0000000..6f4ceb7 --- /dev/null +++ b/ferro-ta-main/fuzz/fuzz_targets/fuzz_sma.rs @@ -0,0 +1,51 @@ +/*! +Fuzz target for `ferro_ta_core::overlap::sma`. + +The fuzzer generates arbitrary byte sequences and interprets them as +`f64` values plus a `timeperiod`. The invariant under test is that the +function **never panics** for any input — it may return `NaN`, `Inf`, or +an all-NaN slice, but it must not crash. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::overlap; + +fuzz_target!(|data: &[u8]| { + // Need at least 1 byte for timeperiod + 8 bytes for one f64 + if data.len() < 2 { + return; + } + + // Extract timeperiod from first byte (1-64 to keep runs fast) + let timeperiod = ((data[0] as usize) % 64) + 1; + + // Interpret remaining bytes as f64 values (skip incomplete trailing bytes) + 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(); + + // Must not panic for any input + let result = overlap::sma(&close, timeperiod); + + // Result length must match input length + assert_eq!(result.len(), close.len(), "SMA output length mismatch"); + + // The first (timeperiod - 1) values must be NaN + for i in 0..(timeperiod.min(close.len()).saturating_sub(1)) { + assert!( + result[i].is_nan(), + "SMA result[{i}] should be NaN (warm-up period)" + ); + } +}); diff --git a/ferro-ta-main/fuzz/fuzz_targets/fuzz_stoch.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_stoch.rs new file mode 100644 index 0000000..178669c --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/fuzz/fuzz_targets/fuzz_wma.rs b/ferro-ta-main/fuzz/fuzz_targets/fuzz_wma.rs new file mode 100644 index 0000000..32ce226 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/perf-contract/batch.json b/ferro-ta-main/perf-contract/batch.json new file mode 100644 index 0000000..77f8bf0 --- /dev/null +++ b/ferro-ta-main/perf-contract/batch.json @@ -0,0 +1,93 @@ +{ + "metadata": { + "suite": "batch", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:04.010216+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": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "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.4" + }, + "dataset": { + "n_samples": 20000, + "n_series": 32, + "total_bars": 640000, + "seed": 42 + } + }, + "results": [ + { + "indicator": "SMA", + "parallel_ms": 7.9306, + "sequential_ms": 2.4832, + "loop_ms": 1.0238, + "parallel_speedup_vs_loop": 0.1291, + "sequential_speedup_vs_loop": 0.4123 + }, + { + "indicator": "RSI", + "parallel_ms": 3.9307, + "sequential_ms": 5.0938, + "loop_ms": 3.6883, + "parallel_speedup_vs_loop": 0.9383, + "sequential_speedup_vs_loop": 0.7241 + }, + { + "indicator": "ATR", + "parallel_ms": 9.2546, + "sequential_ms": 7.768, + "loop_ms": 5.2669, + "parallel_speedup_vs_loop": 0.5691, + "sequential_speedup_vs_loop": 0.678 + }, + { + "indicator": "ADX", + "parallel_ms": 9.5489, + "sequential_ms": 9.1403, + "loop_ms": 7.1578, + "parallel_speedup_vs_loop": 0.7496, + "sequential_speedup_vs_loop": 0.7831 + } + ], + "grouped_results": [ + { + "case": "close_bundle_3", + "grouped_ms": 0.466, + "separate_ms": 0.2003, + "speedup_vs_separate": 0.4298 + }, + { + "case": "hlc_bundle_3", + "grouped_ms": 1.2538, + "separate_ms": 0.6999, + "speedup_vs_separate": 0.5582 + } + ] +} diff --git a/ferro-ta-main/perf-contract/indicator_latency.json b/ferro-ta-main/perf-contract/indicator_latency.json new file mode 100644 index 0000000..1d9e3b2 --- /dev/null +++ b/ferro-ta-main/perf-contract/indicator_latency.json @@ -0,0 +1,185 @@ +{ + "metadata": { + "suite": "indicator_latency", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:02.973728+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": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "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.4" + }, + "fixtures": [ + { + "path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "size_bytes": 75586, + "sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c" + } + ], + "dataset": { + "fixture": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "bars": 2000, + "rounds": 5 + } + }, + "results": [ + { + "name": "VAR_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0233 + }, + { + "name": "WILLR_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0207 + }, + { + "name": "STOCH", + "inputs": "hlc", + "kwargs": {}, + "elapsed_ms": 0.0202 + }, + { + "name": "ADX_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0171 + }, + { + "name": "CCI_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0164 + }, + { + "name": "MACD", + "inputs": "close", + "kwargs": {}, + "elapsed_ms": 0.015 + }, + { + "name": "ATR_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0116 + }, + { + "name": "RSI_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.011 + }, + { + "name": "STDDEV_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0103 + }, + { + "name": "BETA_5", + "inputs": "pair_hl", + "kwargs": { + "timeperiod": 5 + }, + "elapsed_ms": 0.0091 + }, + { + "name": "CORREL_30", + "inputs": "pair_hl", + "kwargs": { + "timeperiod": 30 + }, + "elapsed_ms": 0.0078 + }, + { + "name": "BBANDS_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0062 + }, + { + "name": "TSF_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0056 + }, + { + "name": "EMA_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0055 + }, + { + "name": "LINEARREG_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0054 + }, + { + "name": "LINEARREG_SLOPE_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.005 + }, + { + "name": "SMA_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0032 + } + ] +} diff --git a/ferro-ta-main/perf-contract/manifest.json b/ferro-ta-main/perf-contract/manifest.json new file mode 100644 index 0000000..01b5fe6 --- /dev/null +++ b/ferro-ta-main/perf-contract/manifest.json @@ -0,0 +1,69 @@ +{ + "metadata": { + "suite": "perf_contract", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:08.787230+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": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "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.4" + }, + "fixtures": [ + { + "path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "size_bytes": 75586, + "sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c" + } + ], + "output_dir": "perf-contract" + }, + "artifacts": { + "indicator_latency": { + "path": "perf-contract/indicator_latency.json", + "size_bytes": 4041, + "sha256": "564027c7abed7ecd4ae2ac1720217d96e1e31807f8ac7d5c5393c8fd974f13ed" + }, + "batch": { + "path": "perf-contract/batch.json", + "size_bytes": 2507, + "sha256": "c50f242a138a0c358a6fa86c420954b4b11a2200598ca2942bcd0fd2bc456fb2" + }, + "streaming": { + "path": "perf-contract/streaming.json", + "size_bytes": 2766, + "sha256": "d271d3219ec098e443e84ef648ab4220cd2a38e6dc998bc6c9fc545d7fc77716" + }, + "runtime_hotspots": { + "path": "perf-contract/runtime_hotspots.json", + "size_bytes": 3197, + "sha256": "96eafc9a61ac410e47fcdfee6a9db1123cdee0ec05f49a85c631a3975063deed" + } + } +} diff --git a/ferro-ta-main/perf-contract/runtime_hotspots.json b/ferro-ta-main/perf-contract/runtime_hotspots.json new file mode 100644 index 0000000..2e8e2df --- /dev/null +++ b/ferro-ta-main/perf-contract/runtime_hotspots.json @@ -0,0 +1,118 @@ +{ + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:08.521805+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": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "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.4" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 32.5847, + "reference_ms": 990.4233, + "speedup_vs_reference": 30.3954, + "share_of_suite_pct": 69.27 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 12.2974, + "reference_ms": 233.0671, + "speedup_vs_reference": 18.9525, + "share_of_suite_pct": 26.14 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 0.9511, + "reference_ms": 90.5663, + "speedup_vs_reference": 95.2202, + "share_of_suite_pct": 2.02 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.6619, + "reference_ms": 0.6155, + "speedup_vs_reference": 0.9299, + "share_of_suite_pct": 1.41 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.3175, + "reference_ms": 0.2437, + "speedup_vs_reference": 0.7675, + "share_of_suite_pct": 0.67 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0742, + "reference_ms": 188.6048, + "speedup_vs_reference": 2541.5695, + "share_of_suite_pct": 0.16 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0638, + "reference_ms": 215.973, + "speedup_vs_reference": 3387.8115, + "share_of_suite_pct": 0.14 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0441, + "reference_ms": 49.6108, + "speedup_vs_reference": 1125.3954, + "share_of_suite_pct": 0.09 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.044, + "reference_ms": 57.9675, + "speedup_vs_reference": 1318.7009, + "share_of_suite_pct": 0.09 + } + ] +} diff --git a/ferro-ta-main/perf-contract/simd.json b/ferro-ta-main/perf-contract/simd.json new file mode 100644 index 0000000..b220833 --- /dev/null +++ b/ferro-ta-main/perf-contract/simd.json @@ -0,0 +1,285 @@ +{ + "metadata": { + "suite": "simd", + "runtime": { + "generated_at_utc": "2026-03-23T21:09:13.028473+00:00", + "python_version": "3.12.11", + "platform": "macOS-26.3.1-arm64-arm-64bit", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "2d5000262f0f1439546bd4872235aae0333880a4", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + }, + "variants": [ + "portable_release", + "simd_release" + ] + }, + "results": [ + { + "name": "compute_many_close", + "category": "ffi_grouping", + "portable_ms": 0.1711, + "simd_ms": 0.1618, + "speedup_simd_vs_portable": 1.0575 + }, + { + "name": "iv_percentile", + "category": "python_analysis", + "portable_ms": 0.9126, + "simd_ms": 0.9096, + "speedup_simd_vs_portable": 1.0033 + }, + { + "name": "iv_zscore", + "category": "python_analysis", + "portable_ms": 29.0039, + "simd_ms": 28.9123, + "speedup_simd_vs_portable": 1.0032 + }, + { + "name": "iv_rank", + "category": "python_analysis", + "portable_ms": 10.8629, + "simd_ms": 10.862, + "speedup_simd_vs_portable": 1.0001 + }, + { + "name": "BETA", + "category": "rust_kernel", + "portable_ms": 0.0696, + "simd_ms": 0.0696, + "speedup_simd_vs_portable": 1.0 + }, + { + "name": "CORREL", + "category": "rust_kernel", + "portable_ms": 0.0555, + "simd_ms": 0.0556, + "speedup_simd_vs_portable": 0.9982 + }, + { + "name": "TSF", + "category": "rust_kernel", + "portable_ms": 0.0414, + "simd_ms": 0.0415, + "speedup_simd_vs_portable": 0.9976 + }, + { + "name": "LINEARREG", + "category": "rust_kernel", + "portable_ms": 0.0413, + "simd_ms": 0.0416, + "speedup_simd_vs_portable": 0.9928 + }, + { + "name": "feature_matrix", + "category": "ffi_grouping", + "portable_ms": 0.2543, + "simd_ms": 0.2634, + "speedup_simd_vs_portable": 0.9655 + } + ], + "reports": { + "portable_release": { + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T21:08:38.698373+00:00", + "python_version": "3.12.11", + "platform": "macOS-26.3.1-arm64-arm-64bit", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "2d5000262f0f1439546bd4872235aae0333880a4", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 29.0039, + "reference_ms": 903.5384, + "speedup_vs_reference": 31.1523, + "share_of_suite_pct": 70.04 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 10.8629, + "reference_ms": 201.621, + "speedup_vs_reference": 18.5606, + "share_of_suite_pct": 26.23 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 0.9126, + "reference_ms": 80.0849, + "speedup_vs_reference": 87.7523, + "share_of_suite_pct": 2.2 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2543, + "reference_ms": 0.2222, + "speedup_vs_reference": 0.874, + "share_of_suite_pct": 0.61 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1711, + "reference_ms": 0.1413, + "speedup_vs_reference": 0.8257, + "share_of_suite_pct": 0.41 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0696, + "reference_ms": 162.9168, + "speedup_vs_reference": 2341.2971, + "share_of_suite_pct": 0.17 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0555, + "reference_ms": 163.8589, + "speedup_vs_reference": 2950.1793, + "share_of_suite_pct": 0.13 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0414, + "reference_ms": 49.2846, + "speedup_vs_reference": 1189.9901, + "share_of_suite_pct": 0.1 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0413, + "reference_ms": 47.5241, + "speedup_vs_reference": 1149.7853, + "share_of_suite_pct": 0.1 + } + ] + }, + "simd_release": { + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T21:08:57.755423+00:00", + "python_version": "3.12.11", + "platform": "macOS-26.3.1-arm64-arm-64bit", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "2d5000262f0f1439546bd4872235aae0333880a4", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 28.9123, + "reference_ms": 909.2586, + "speedup_vs_reference": 31.4489, + "share_of_suite_pct": 69.98 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 10.862, + "reference_ms": 198.2076, + "speedup_vs_reference": 18.2478, + "share_of_suite_pct": 26.29 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 0.9096, + "reference_ms": 78.1232, + "speedup_vs_reference": 85.889, + "share_of_suite_pct": 2.2 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2634, + "reference_ms": 0.2219, + "speedup_vs_reference": 0.8425, + "share_of_suite_pct": 0.64 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1618, + "reference_ms": 0.155, + "speedup_vs_reference": 0.9583, + "share_of_suite_pct": 0.39 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0696, + "reference_ms": 161.4576, + "speedup_vs_reference": 2320.3601, + "share_of_suite_pct": 0.17 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0556, + "reference_ms": 162.6189, + "speedup_vs_reference": 2923.4861, + "share_of_suite_pct": 0.13 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0416, + "reference_ms": 48.029, + "speedup_vs_reference": 1155.0143, + "share_of_suite_pct": 0.1 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0415, + "reference_ms": 47.55, + "speedup_vs_reference": 1145.783, + "share_of_suite_pct": 0.1 + } + ] + } + } +} diff --git a/ferro-ta-main/perf-contract/streaming.json b/ferro-ta-main/perf-contract/streaming.json new file mode 100644 index 0000000..239adce --- /dev/null +++ b/ferro-ta-main/perf-contract/streaming.json @@ -0,0 +1,95 @@ +{ + "metadata": { + "suite": "streaming", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:04.351797+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": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "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.4" + }, + "dataset": { + "n_bars": 20000, + "seed": 2026 + } + }, + "results": [ + { + "indicator": "StreamingSMA", + "inputs": "close", + "stream_total_ms": 0.9927, + "batch_total_ms": 0.0166, + "stream_ns_per_update": 49.63, + "batch_ns_per_bar": 0.83, + "updates_per_second": 20147763.43, + "stream_over_batch_ratio": 59.7092 + }, + { + "indicator": "StreamingEMA", + "inputs": "close", + "stream_total_ms": 0.9459, + "batch_total_ms": 0.0435, + "stream_ns_per_update": 47.3, + "batch_ns_per_bar": 2.18, + "updates_per_second": 21143503.9, + "stream_over_batch_ratio": 21.7247 + }, + { + "indicator": "StreamingRSI", + "inputs": "close", + "stream_total_ms": 1.0059, + "batch_total_ms": 0.0991, + "stream_ns_per_update": 50.3, + "batch_ns_per_bar": 4.95, + "updates_per_second": 19882356.06, + "stream_over_batch_ratio": 10.1523 + }, + { + "indicator": "StreamingATR", + "inputs": "hlc", + "stream_total_ms": 2.1574, + "batch_total_ms": 0.0992, + "stream_ns_per_update": 107.87, + "batch_ns_per_bar": 4.96, + "updates_per_second": 9270525.53, + "stream_over_batch_ratio": 21.746 + }, + { + "indicator": "StreamingVWAP", + "inputs": "hlcv", + "stream_total_ms": 2.6935, + "batch_total_ms": 0.0252, + "stream_ns_per_update": 134.68, + "batch_ns_per_bar": 1.26, + "updates_per_second": 7425170.07, + "stream_over_batch_ratio": 106.8484 + } + ] +} diff --git a/ferro-ta-main/pyproject.toml b/ferro-ta-main/pyproject.toml new file mode 100644 index 0000000..01d14b3 --- /dev/null +++ b/ferro-ta-main/pyproject.toml @@ -0,0 +1,167 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "ferro-ta" +version = "1.2.0" +description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +keywords = [ + "technical-analysis", "trading", "finance", "rust", "pyo3", + "ta-lib", "indicators", "candlestick", "pandas", "numpy", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Financial and Insurance Industry", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", + "Topic :: Office/Business :: Financial", + "Topic :: Scientific/Engineering :: Mathematics", + "Typing :: Typed", +] +dependencies = ["numpy>=1.20"] + +[project.optional-dependencies] +test = ["pytest>=7.0", "hypothesis>=6.0"] +benchmark = ["pytest>=7.0", "pytest-benchmark>=4.0"] +pandas = ["pandas>=1.0"] +polars = ["polars>=0.19"] +docs = ["sphinx>=7.0", "sphinx-rtd-theme>=1.3"] +comparison = [ + "pytest>=7.0", + "ta-lib>=0.4", + "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 = [] +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", + "pre-commit>=3.0", + "ruff>=0.3", + "mypy>=1.0", + "pyright>=1.1", + "maturin>=1.0,<2.0", + "pyyaml>=6.0", + "matplotlib>=3.5", + "fastapi>=0.135.1", + "httpx>=0.24", + "scipy>=1.10", +] + +[project.urls] +Homepage = "https://github.com/pratikbhadane24/ferro-ta" +Repository = "https://github.com/pratikbhadane24/ferro-ta" +"Bug Tracker" = "https://github.com/pratikbhadane24/ferro-ta/issues" + +[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 = 80 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ ==", +] + +[tool.maturin] +python-source = "python" +module-name = "ferro_ta._ferro_ta" +features = ["pyo3/extension-module"] +# Include the PEP 561 py.typed marker so type checkers recognize this package +include = [{ path = "python/ferro_ta/py.typed", format = "wheel" }] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +ignore_missing_imports = true +# Strictness: step toward strict; fix critical issues first +disallow_untyped_defs = false +check_untyped_defs = true +# Allow for now; tighten once missing-return and no-any-return are fixed +disable_error_code = ["return", "no-any-return"] + +[tool.ruff] +target-version = "py310" +line-length = 88 +src = ["python", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501", "UP006", "UP045", "UP007"] + +# Tests: allow underscore in class names (TestCHANDELIER_EXIT), late imports (E402), uppercase helpers (N802) +# Stubs: public API names are uppercase (SMA, RSI, etc.); Tuple used for compatibility +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["N801", "N802", "N806", "E402", "E741", "F811"] +"tests/unit/*" = ["N801", "N802", "N806", "E402", "E741", "F811"] +"tests/integration/*" = ["N801", "N802", "N806", "E402", "E741", "F811"] +"benchmarks/*" = ["E402", "E741"] +"python/ferro_ta/*.py" = ["N802"] # Public API: SMA, RSI, ATR, etc. +"python/ferro_ta/__init__.py" = ["E402", "N802"] # Re-export surface by design +"python/ferro_ta/__init__.pyi" = ["N802", "E402"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pyright] +pythonVersion = "3.10" +typeCheckingMode = "basic" +reportMissingImports = false +reportMissingTypeStubs = false +reportMissingModuleSource = false + +[tool.uv] +# Use uv for dependency resolution, locking, and running commands. +# Install: pip install uv (or curl -Lsf https://astral.sh/uv/install.sh | sh) +# Sync: uv sync --extra dev +# Tests: uv run pytest tests/ +# Build: uv run maturin build --release --out dist +constraint-dependencies = [ + "pygments>=2.20.0", + "requests>=2.33.0", +] + +[dependency-groups] +dev = [ + "pytest>=7.0", + "hypothesis>=6.0", + "pandas>=1.0", + "polars>=0.19", + "pre-commit>=3.0", + "ruff>=0.3", + "mypy>=1.0", + "pyright>=1.1", + "maturin>=1.0,<2.0", + "pyyaml>=6.0", + "pandas-ta>=0.3; python_version >= '3.12'", + "ta>=0.10", + "scipy>=1.15.3", +] diff --git a/ferro-ta-main/python/ferro_ta/__init__.py b/ferro-ta-main/python/ferro_ta/__init__.py new file mode 100644 index 0000000..8fc10be --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/__init__.py @@ -0,0 +1,674 @@ +""" +ferro_ta — A fast Technical Analysis library powered by Rust and PyO3. + +Drop-in alternative to TA-Lib with pre-compiled wheels for all platforms. + +Indicators are organized into sub-modules matching TA-Lib's category structure, +and are also importable directly from this top-level package for convenience. + +Sub-packages +------------ +* :mod:`ferro_ta.indicators` — All indicator functions (overlap, momentum, volume, volatility, statistic, cycle, pattern, price_transform, math_ops, extended) +* :mod:`ferro_ta.core` — Core utilities (exceptions, config, logging, registry, raw) +* :mod:`ferro_ta.data` — Data utilities (streaming, batch, chunked, resampling, aggregation, adapters) +* :mod:`ferro_ta.analysis` — Analysis tools (portfolio, backtest, regime, cross_asset, attribution, signals, features, crypto, options, futures, derivatives payoff) +* :mod:`ferro_ta.tools` — Developer tools (tools, viz, dashboard, alerts, dsl, pipeline, workflow, api_info, gpu) + +Sub-modules (also accessible via sub-packages above) +----------------------------------------------------- +* :mod:`ferro_ta.indicators.overlap` — Overlap Studies (SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MACD, BBANDS, SAR, MA, MAVP, MAMA, SAREXT, MACDEXT, …) +* :mod:`ferro_ta.indicators.momentum` — Momentum Indicators (RSI, STOCH, ADX, CCI, WILLR, AROON, MFI, …) +* :mod:`ferro_ta.indicators.volume` — Volume Indicators (AD, ADOSC, OBV) +* :mod:`ferro_ta.indicators.volatility` — Volatility Indicators (ATR, NATR, TRANGE) +* :mod:`ferro_ta.indicators.statistic` — Statistic Functions (STDDEV, VAR, LINEARREG, BETA, CORREL, …) +* :mod:`ferro_ta.indicators.price_transform` — Price Transformations (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE) +* :mod:`ferro_ta.indicators.pattern` — Pattern Recognition (CDLDOJI, CDLENGULFING, CDLHAMMER, …) +* :mod:`ferro_ta.indicators.cycle` — Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE) +* :mod:`ferro_ta.indicators.math_ops` — Math Operators/Transforms (ADD, SUB, MULT, DIV, SUM, MAX, MIN, ACOS, SIN, …) +* :mod:`ferro_ta.indicators.extended` — Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) +* :mod:`ferro_ta.data.streaming` — Streaming / Incremental API (bar-by-bar stateful classes for live trading) +* :mod:`ferro_ta.data.batch` — Batch Execution API (run SMA/EMA/RSI on 2-D arrays of multiple series) +* :mod:`ferro_ta.data.resampling` — OHLCV resampling and multi-timeframe API +* :mod:`ferro_ta.data.aggregation` — Tick/trade aggregation pipeline +* :mod:`ferro_ta.tools.dsl` — Strategy expression DSL +* :mod:`ferro_ta.analysis.signals` — Signal composition and screening +* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics +* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative strength +* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness +* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, smile, and chain analytics +* :mod:`ferro_ta.analysis.futures` — Futures basis, carry, roll, and curve analytics +* :mod:`ferro_ta.tools.viz` — Charting and visualisation API +* :mod:`ferro_ta.data.adapters` — Market data adapters + +Usage +----- +>>> import numpy as np +>>> from ferro_ta import SMA, EMA, RSI, MACD, BBANDS +>>> close = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 12.5]) +>>> SMA(close, timeperiod=3) +array([ nan, nan, 11. , 12. , 13. , 13.5, 13.33...]) + +>>> # Or import from sub-packages: +>>> from ferro_ta.indicators.overlap import SMA, BBANDS +>>> from ferro_ta.indicators.momentum import RSI, ADX +>>> from ferro_ta.indicators.volatility import ATR +>>> from ferro_ta.indicators.cycle import HT_TRENDLINE, HT_DCPERIOD +>>> # Backward-compat flat imports still work: +>>> from ferro_ta.overlap import SMA # noqa: F401 (stub) +""" + +from __future__ import annotations + +import re as _re +import sys as _sys +from importlib.metadata import PackageNotFoundError as _PackageNotFoundError +from importlib.metadata import version as _dist_version +from pathlib import Path as _Path + +try: + import tomllib as _tomllib +except ImportError: # pragma: no cover + try: + import tomli as _tomllib # type: ignore[no-redef] + except ImportError: # pragma: no cover + _tomllib = None # type: ignore[assignment] + + +def _detect_version() -> str: + try: + return _dist_version("ferro-ta") + except _PackageNotFoundError: + pass + + if _tomllib is not None: + pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml" + if pyproject_toml.is_file(): + try: + with pyproject_toml.open("rb") as handle: + data = _tomllib.load(handle) + return data.get("project", {}).get("version", "0+unknown") + except Exception: + pass + + pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml" + if pyproject_toml.is_file(): + try: + text = pyproject_toml.read_text(encoding="utf-8") + match = _re.search(r'^version\s*=\s*"([^"]+)"', text, _re.MULTILINE) + if match: + return match.group(1) + except Exception: + pass + + return "0+unknown" + + +__version__ = _detect_version() + +# --------------------------------------------------------------------------- +# Exceptions — exported at the top level for convenient catching +# --------------------------------------------------------------------------- +from ferro_ta.core.exceptions import ( # noqa: F401 + FerroTAError, + FerroTaError, + FerroTAInputError, + FerroTAValueError, + InsufficientDataError, + InvalidInputError, + InvalidPeriodError, + LengthMismatchError, + NumericConvergenceError, +) + +# --------------------------------------------------------------------------- +# Cycle Indicators +# --------------------------------------------------------------------------- +from ferro_ta.indicators.cycle import ( # noqa: F401 + HT_DCPERIOD, + HT_DCPHASE, + HT_PHASOR, + HT_SINE, + HT_TRENDLINE, + HT_TRENDMODE, +) + +# --------------------------------------------------------------------------- +# Math Operators & Math Transforms +# --------------------------------------------------------------------------- +from ferro_ta.indicators.math_ops import ( # noqa: F401 + ACOS, + ADD, + ASIN, + ATAN, + CEIL, + COS, + COSH, + DIV, + EXP, + FLOOR, + LN, + LOG10, + MAX, + MAXINDEX, + MIN, + MININDEX, + MULT, + SIN, + SINH, + SQRT, + SUB, + SUM, + TAN, + TANH, +) + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- +from ferro_ta.indicators.momentum import ( # noqa: F401 + ADX, + ADXR, + APO, + AROON, + AROONOSC, + BOP, + CCI, + CMO, + DX, + MFI, + MINUS_DI, + MINUS_DM, + MOM, + PLUS_DI, + PLUS_DM, + PPO, + ROC, + ROCP, + ROCR, + ROCR100, + RSI, + STOCH, + STOCHF, + STOCHRSI, + TRANGE, + TRIX, + ULTOSC, + WILLR, +) + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- +from ferro_ta.indicators.overlap import ( # noqa: F401 + BBANDS, + DEMA, + EMA, + KAMA, + MA, + MACD, + MACDEXT, + MACDFIX, + MAMA, + MAVP, + MIDPOINT, + MIDPRICE, + SAR, + SAREXT, + SMA, + T3, + TEMA, + TRIMA, + WMA, +) + +# --------------------------------------------------------------------------- +# Pattern Recognition +# --------------------------------------------------------------------------- +from ferro_ta.indicators.pattern import ( # noqa: F401 + CDL2CROWS, + CDL3BLACKCROWS, + CDL3INSIDE, + CDL3LINESTRIKE, + CDL3OUTSIDE, + CDL3STARSINSOUTH, + CDL3WHITESOLDIERS, + CDLABANDONEDBABY, + CDLADVANCEBLOCK, + CDLBELTHOLD, + CDLBREAKAWAY, + CDLCLOSINGMARUBOZU, + CDLCONCEALBABYSWALL, + CDLCOUNTERATTACK, + CDLDARKCLOUDCOVER, + CDLDOJI, + CDLDOJISTAR, + CDLDRAGONFLYDOJI, + CDLENGULFING, + CDLEVENINGDOJISTAR, + CDLEVENINGSTAR, + CDLGAPSIDESIDEWHITE, + CDLGRAVESTONEDOJI, + CDLHAMMER, + CDLHANGINGMAN, + CDLHARAMI, + CDLHARAMICROSS, + CDLHIGHWAVE, + CDLHIKKAKE, + CDLHIKKAKEMOD, + CDLHOMINGPIGEON, + CDLIDENTICAL3CROWS, + CDLINNECK, + CDLINVERTEDHAMMER, + CDLKICKING, + CDLKICKINGBYLENGTH, + CDLLADDERBOTTOM, + CDLLONGLEGGEDDOJI, + CDLLONGLINE, + CDLMARUBOZU, + CDLMATCHINGLOW, + CDLMATHOLD, + CDLMORNINGDOJISTAR, + CDLMORNINGSTAR, + CDLONNECK, + CDLPIERCING, + CDLRICKSHAWMAN, + CDLRISEFALL3METHODS, + CDLSEPARATINGLINES, + CDLSHOOTINGSTAR, + CDLSHORTLINE, + CDLSPINNINGTOP, + CDLSTALLEDPATTERN, + CDLSTICKSANDWICH, + CDLTAKURI, + CDLTASUKIGAP, + CDLTHRUSTING, + CDLTRISTAR, + CDLUNIQUE3RIVER, + CDLUPSIDEGAP2CROWS, + CDLXSIDEGAP3METHODS, +) + +# --------------------------------------------------------------------------- +# Price Transformations +# --------------------------------------------------------------------------- +from ferro_ta.indicators.price_transform import ( # noqa: F401 + AVGPRICE, + MEDPRICE, + TYPPRICE, + WCLPRICE, +) + +# --------------------------------------------------------------------------- +# Statistic Functions +# --------------------------------------------------------------------------- +from ferro_ta.indicators.statistic import ( # noqa: F401 + BETA, + CORREL, + LINEARREG, + LINEARREG_ANGLE, + LINEARREG_INTERCEPT, + LINEARREG_SLOPE, + STDDEV, + TSF, + VAR, +) + +# --------------------------------------------------------------------------- +# Volatility Indicators +# --------------------------------------------------------------------------- +from ferro_ta.indicators.volatility import ( # noqa: F401 + ATR, + NATR, +) + +# --------------------------------------------------------------------------- +# Volume Indicators +# --------------------------------------------------------------------------- +from ferro_ta.indicators.volume import ( # noqa: F401 + AD, + ADOSC, + OBV, +) + +__all__ = [ + "__version__", + # Overlap Studies + "SMA", + "EMA", + "WMA", + "DEMA", + "TEMA", + "TRIMA", + "KAMA", + "T3", + "BBANDS", + "MACD", + "MACDFIX", + "MACDEXT", + "SAR", + "SAREXT", + "MA", + "MAVP", + "MAMA", + "MIDPOINT", + "MIDPRICE", + # Momentum + "RSI", + "MOM", + "ROC", + "ROCP", + "ROCR", + "ROCR100", + "WILLR", + "AROON", + "AROONOSC", + "CCI", + "MFI", + "BOP", + "STOCHF", + "STOCH", + "STOCHRSI", + "APO", + "PPO", + "CMO", + "PLUS_DM", + "MINUS_DM", + "PLUS_DI", + "MINUS_DI", + "DX", + "ADX", + "ADXR", + "TRIX", + "ULTOSC", + "TRANGE", + # Volume + "AD", + "ADOSC", + "OBV", + # Volatility + "ATR", + "NATR", + # Statistics + "STDDEV", + "VAR", + "LINEARREG", + "LINEARREG_SLOPE", + "LINEARREG_INTERCEPT", + "LINEARREG_ANGLE", + "TSF", + "BETA", + "CORREL", + # Price transforms + "AVGPRICE", + "MEDPRICE", + "TYPPRICE", + "WCLPRICE", + # Patterns + "CDL2CROWS", + "CDL3BLACKCROWS", + "CDL3INSIDE", + "CDL3LINESTRIKE", + "CDL3OUTSIDE", + "CDL3STARSINSOUTH", + "CDL3WHITESOLDIERS", + "CDLABANDONEDBABY", + "CDLADVANCEBLOCK", + "CDLBELTHOLD", + "CDLBREAKAWAY", + "CDLCLOSINGMARUBOZU", + "CDLCONCEALBABYSWALL", + "CDLCOUNTERATTACK", + "CDLDARKCLOUDCOVER", + "CDLDOJI", + "CDLDOJISTAR", + "CDLDRAGONFLYDOJI", + "CDLENGULFING", + "CDLEVENINGDOJISTAR", + "CDLEVENINGSTAR", + "CDLGAPSIDESIDEWHITE", + "CDLGRAVESTONEDOJI", + "CDLHAMMER", + "CDLHANGINGMAN", + "CDLHARAMI", + "CDLHARAMICROSS", + "CDLHIGHWAVE", + "CDLHIKKAKE", + "CDLHIKKAKEMOD", + "CDLHOMINGPIGEON", + "CDLIDENTICAL3CROWS", + "CDLINNECK", + "CDLINVERTEDHAMMER", + "CDLKICKING", + "CDLKICKINGBYLENGTH", + "CDLLADDERBOTTOM", + "CDLLONGLEGGEDDOJI", + "CDLLONGLINE", + "CDLMARUBOZU", + "CDLMATCHINGLOW", + "CDLMATHOLD", + "CDLMORNINGDOJISTAR", + "CDLMORNINGSTAR", + "CDLONNECK", + "CDLPIERCING", + "CDLRICKSHAWMAN", + "CDLRISEFALL3METHODS", + "CDLSEPARATINGLINES", + "CDLSHOOTINGSTAR", + "CDLSHORTLINE", + "CDLSPINNINGTOP", + "CDLSTALLEDPATTERN", + "CDLSTICKSANDWICH", + "CDLTAKURI", + "CDLTASUKIGAP", + "CDLTHRUSTING", + "CDLTRISTAR", + "CDLUNIQUE3RIVER", + "CDLUPSIDEGAP2CROWS", + "CDLXSIDEGAP3METHODS", + # Cycle + "HT_TRENDLINE", + "HT_DCPERIOD", + "HT_DCPHASE", + "HT_PHASOR", + "HT_SINE", + "HT_TRENDMODE", + # Math Operators + "ADD", + "SUB", + "MULT", + "DIV", + "SUM", + "MAX", + "MIN", + "MAXINDEX", + "MININDEX", + # Math Transforms + "ACOS", + "ASIN", + "ATAN", + "CEIL", + "COS", + "COSH", + "EXP", + "FLOOR", + "LN", + "LOG10", + "SIN", + "SINH", + "SQRT", + "TAN", + "TANH", + # Extended Indicators + "VWAP", + "SUPERTREND", + "ICHIMOKU", + "DONCHIAN", + "PIVOT_POINTS", + "KELTNER_CHANNELS", + "HULL_MA", + "CHANDELIER_EXIT", + "VWMA", + "CHOPPINESS_INDEX", + # API discovery + "about", + "indicators", + "methods", + "info", + # Logging utilities + "enable_debug", + "disable_debug", + "debug_mode", + "get_logger", + "log_call", + "benchmark", + "traced", +] + +# --------------------------------------------------------------------------- +# Extended Indicators +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Pandas API — apply transparent pandas.Series / DataFrame support to every +# public indicator function exported from this module. +# --------------------------------------------------------------------------- +from ferro_ta._utils import pandas_wrap as _pandas_wrap # noqa: E402 +from ferro_ta._utils import polars_wrap as _polars_wrap # noqa: E402 +from ferro_ta.analysis.attribution import ( # noqa: F401, E402 + TradeStats, + attribution_by_month, + attribution_by_signal, + from_backtest, + trade_stats, +) +from ferro_ta.analysis.crypto import ( # noqa: F401, E402 + continuous_bar_labels, + funding_pnl, + resample_continuous, + session_boundaries, +) +from ferro_ta.analysis.regime import ( # noqa: F401, E402 + detect_breaks_cusum, + regime, + regime_adx, + regime_combined, + rolling_variance_break, + structural_breaks, +) +from ferro_ta.core import exceptions as exceptions # noqa: F401, E402 + +# --------------------------------------------------------------------------- +# Logging utilities — ferro_ta.enable_debug() / ferro_ta.benchmark() +# --------------------------------------------------------------------------- +from ferro_ta.core.logging_utils import ( # noqa: F401, E402 + benchmark, + debug_mode, + disable_debug, + enable_debug, + get_logger, + log_call, + traced, +) +from ferro_ta.data import batch as batch # noqa: F401, E402 +from ferro_ta.data import streaming as streaming # noqa: F401, E402 + +# --------------------------------------------------------------------------- +# Batch API (not in __all__ — use directly from ferro_ta.batch) +# Import: from ferro_ta.batch import batch_sma, batch_ema, batch_rsi +# --------------------------------------------------------------------------- +from ferro_ta.data.batch import ( # noqa: F401, E402 + batch_apply, + batch_ema, + batch_rsi, + batch_sma, + compute_many, +) +from ferro_ta.data.chunked import ( # noqa: F401, E402 + chunk_apply, + make_chunk_ranges, + stitch_chunks, + trim_overlap, +) + +# --------------------------------------------------------------------------- +# Streaming / Incremental API (not in __all__ — these are classes, not funcs) +# Import directly: from ferro_ta.streaming import StreamingSMA, ... +# --------------------------------------------------------------------------- +from ferro_ta.data.streaming import ( # noqa: F401, E402 # type: ignore[assignment] + StreamingATR, # type: ignore[attr-defined] + StreamingBBands, # type: ignore[attr-defined] + StreamingEMA, # type: ignore[attr-defined] + StreamingMACD, # type: ignore[attr-defined] + StreamingRSI, # type: ignore[attr-defined] + StreamingSMA, # type: ignore[attr-defined] + StreamingStoch, # type: ignore[attr-defined] + StreamingSupertrend, # type: ignore[attr-defined] + StreamingVWAP, # type: ignore[attr-defined] +) +from ferro_ta.indicators import cycle as cycle # noqa: F401, E402 +from ferro_ta.indicators import extended as extended # noqa: F401, E402 +from ferro_ta.indicators import math_ops as math_ops # noqa: F401, E402 +from ferro_ta.indicators import momentum as momentum # noqa: F401, E402 +from ferro_ta.indicators import overlap as overlap # noqa: F401, E402 +from ferro_ta.indicators import pattern as pattern # noqa: F401, E402 +from ferro_ta.indicators import price_transform as price_transform # noqa: F401, E402 +from ferro_ta.indicators import statistic as statistic # noqa: F401, E402 +from ferro_ta.indicators import volatility as volatility # noqa: F401, E402 +from ferro_ta.indicators import volume as volume # noqa: F401, E402 +from ferro_ta.indicators.extended import ( # noqa: F401, E402 + CHANDELIER_EXIT, + CHOPPINESS_INDEX, + DONCHIAN, + HULL_MA, + ICHIMOKU, + KELTNER_CHANNELS, + PIVOT_POINTS, + SUPERTREND, + VWAP, + VWMA, +) + +# --------------------------------------------------------------------------- +# Additional modules (not in __all__ — access via submodule) +# --------------------------------------------------------------------------- +from ferro_ta.tools.alerts import ( # noqa: F401, E402 + AlertManager, + check_cross, + check_threshold, + collect_alert_bars, +) + +# --------------------------------------------------------------------------- +# API discovery helpers — ferro_ta.about(), ferro_ta.methods(), +# ferro_ta.indicators(), and ferro_ta.info() +# --------------------------------------------------------------------------- +from ferro_ta.tools.api_info import about, indicators, info, methods # noqa: F401, E402 + +_ALIASED_SUBMODULES = { + "batch": batch, + "cycle": cycle, + "exceptions": exceptions, + "extended": extended, + "math_ops": math_ops, + "momentum": momentum, + "overlap": overlap, + "pattern": pattern, + "price_transform": price_transform, + "statistic": statistic, + "streaming": streaming, + "volatility": volatility, + "volume": volume, +} + +for _module_name, _module in _ALIASED_SUBMODULES.items(): + setattr(_sys.modules[__name__], _module_name, _module) + _sys.modules[f"{__name__}.{_module_name}"] = _module + +_g = globals() +for _name in __all__: + _fn = _g.get(_name) + if callable(_fn) and not getattr(_fn, "_pandas_wrapped", False): + _g[_name] = _pandas_wrap(_fn) + _fn = _g.get(_name) + if callable(_fn) and not getattr(_fn, "_polars_wrapped", False): + _g[_name] = _polars_wrap(_fn) +del _ALIASED_SUBMODULES, _g, _module, _module_name, _name, _fn, _sys diff --git a/ferro-ta-main/python/ferro_ta/__init__.pyi b/ferro-ta-main/python/ferro_ta/__init__.pyi new file mode 100644 index 0000000..e4b707b --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/__init__.pyi @@ -0,0 +1,765 @@ +""" +type stubs for ferro_ta. +Generated for IDE auto-completion and static type checking. +""" + +import logging +from collections.abc import Callable +from contextlib import AbstractContextManager +from typing import Any, TypeVar + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +_F = TypeVar("_F", bound=Callable[..., Any]) + +__version__: str + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- + +def SMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def EMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def WMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def DEMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def TEMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def TRIMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def KAMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def T3( + real: ArrayLike, timeperiod: int = 5, vfactor: float = 0.7 +) -> NDArray[np.float64]: ... +def BBANDS( + real: ArrayLike, + timeperiod: int = 5, + nbdevup: float = 2.0, + nbdevdn: float = 2.0, + matype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def MACD( + real: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def MACDFIX( + real: ArrayLike, + signalperiod: int = 9, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def MACDEXT( + real: ArrayLike, + fastperiod: int = 12, + fastmatype: int = 0, + slowperiod: int = 26, + slowmatype: int = 0, + signalperiod: int = 9, + signalmatype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def SAR( + high: ArrayLike, + low: ArrayLike, + acceleration: float = 0.02, + maximum: float = 0.2, +) -> NDArray[np.float64]: ... +def SAREXT( + high: ArrayLike, + low: ArrayLike, + startvalue: float = 0.0, + offsetonreverse: float = 0.0, + accelerationinitlong: float = 0.02, + accelerationlong: float = 0.02, + accelerationmaxlong: float = 0.2, + accelerationinitshort: float = 0.02, + accelerationshort: float = 0.02, + accelerationmaxshort: float = 0.2, +) -> NDArray[np.float64]: ... +def MA( + real: ArrayLike, timeperiod: int = 30, matype: int = 0 +) -> NDArray[np.float64]: ... +def MAVP( + real: ArrayLike, + periods: ArrayLike, + minperiod: int = 2, + maxperiod: int = 30, + matype: int = 0, +) -> NDArray[np.float64]: ... +def MAMA( + real: ArrayLike, + fastlimit: float = 0.5, + slowlimit: float = 0.05, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def MIDPOINT(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def MIDPRICE( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- + +def RSI(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def MOM(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def ROC(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def ROCP(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def ROCR(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def ROCR100(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def WILLR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def AROON( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def AROONOSC( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def CCI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def MFI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def BOP( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> NDArray[np.float64]: ... +def STOCHF( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + fastd_period: int = 3, + fastd_matype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def STOCH( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + slowk_period: int = 3, + slowk_matype: int = 0, + slowd_period: int = 3, + slowd_matype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def STOCHRSI( + real: ArrayLike, + timeperiod: int = 14, + fastk_period: int = 5, + fastd_period: int = 3, + fastd_matype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def APO( + real: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + matype: int = 0, +) -> NDArray[np.float64]: ... +def PPO( + real: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + matype: int = 0, +) -> NDArray[np.float64]: ... +def CMO(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def PLUS_DM( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def MINUS_DM( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def PLUS_DI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def MINUS_DI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def DX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def ADX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def ADXR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def TRIX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def ULTOSC( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod1: int = 7, + timeperiod2: int = 14, + timeperiod3: int = 28, +) -> NDArray[np.float64]: ... +def TRANGE( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Volume Indicators +# --------------------------------------------------------------------------- + +def AD( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, +) -> NDArray[np.float64]: ... +def ADOSC( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + fastperiod: int = 3, + slowperiod: int = 10, +) -> NDArray[np.float64]: ... +def OBV( + real: ArrayLike, + volume: ArrayLike, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Volatility Indicators +# --------------------------------------------------------------------------- + +def ATR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def NATR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Statistic Functions +# --------------------------------------------------------------------------- + +def STDDEV( + real: ArrayLike, + timeperiod: int = 5, + nbdev: float = 1.0, +) -> NDArray[np.float64]: ... +def VAR( + real: ArrayLike, + timeperiod: int = 5, + nbdev: float = 1.0, +) -> NDArray[np.float64]: ... +def LINEARREG(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def LINEARREG_SLOPE(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def LINEARREG_INTERCEPT( + real: ArrayLike, timeperiod: int = 14 +) -> NDArray[np.float64]: ... +def LINEARREG_ANGLE(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def TSF(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def BETA( + real0: ArrayLike, + real1: ArrayLike, + timeperiod: int = 5, +) -> NDArray[np.float64]: ... +def CORREL( + real0: ArrayLike, + real1: ArrayLike, + timeperiod: int = 30, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Price Transforms +# --------------------------------------------------------------------------- + +def AVGPRICE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> NDArray[np.float64]: ... +def MEDPRICE(high: ArrayLike, low: ArrayLike) -> NDArray[np.float64]: ... +def TYPPRICE( + high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.float64]: ... +def WCLPRICE( + high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Cycle Indicators +# --------------------------------------------------------------------------- + +def HT_TRENDLINE(real: ArrayLike) -> NDArray[np.float64]: ... +def HT_DCPERIOD(real: ArrayLike) -> NDArray[np.float64]: ... +def HT_DCPHASE(real: ArrayLike) -> NDArray[np.float64]: ... +def HT_PHASOR(real: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def HT_SINE(real: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def HT_TRENDMODE(real: ArrayLike) -> NDArray[np.int32]: ... + +# --------------------------------------------------------------------------- +# Math Operators +# --------------------------------------------------------------------------- + +def ADD(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ... +def SUB(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ... +def MULT(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ... +def DIV(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ... +def SUM(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def MAX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def MIN(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.int32]: ... +def MININDEX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.int32]: ... + +# Math Transforms +def ACOS(real: ArrayLike) -> NDArray[np.float64]: ... +def ASIN(real: ArrayLike) -> NDArray[np.float64]: ... +def ATAN(real: ArrayLike) -> NDArray[np.float64]: ... +def CEIL(real: ArrayLike) -> NDArray[np.float64]: ... +def COS(real: ArrayLike) -> NDArray[np.float64]: ... +def COSH(real: ArrayLike) -> NDArray[np.float64]: ... +def EXP(real: ArrayLike) -> NDArray[np.float64]: ... +def FLOOR(real: ArrayLike) -> NDArray[np.float64]: ... +def LN(real: ArrayLike) -> NDArray[np.float64]: ... +def LOG10(real: ArrayLike) -> NDArray[np.float64]: ... +def SIN(real: ArrayLike) -> NDArray[np.float64]: ... +def SINH(real: ArrayLike) -> NDArray[np.float64]: ... +def SQRT(real: ArrayLike) -> NDArray[np.float64]: ... +def TAN(real: ArrayLike) -> NDArray[np.float64]: ... +def TANH(real: ArrayLike) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Extended Indicators (Phase 8 + 9) +# --------------------------------------------------------------------------- + +def VWAP( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 0, +) -> NDArray[np.float64]: ... +def SUPERTREND( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 7, + multiplier: float = 3.0, +) -> tuple[NDArray[np.float64], NDArray[np.int8]]: ... +def ICHIMOKU( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + tenkan_period: int = 9, + kijun_period: int = 26, + senkou_b_period: int = 52, + displacement: int = 26, +) -> tuple[ + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], +]: ... +def DONCHIAN( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 20, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def PIVOT_POINTS( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + method: str = "classic", +) -> tuple[ + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], +]: ... +def KELTNER_CHANNELS( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 20, + atr_period: int = 10, + multiplier: float = 2.0, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def HULL_MA( + close: ArrayLike, + timeperiod: int = 16, +) -> NDArray[np.float64]: ... +def CHANDELIER_EXIT( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 22, + multiplier: float = 3.0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def VWMA( + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 20, +) -> NDArray[np.float64]: ... +def CHOPPINESS_INDEX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Streaming / Incremental API (Phase 3) +# --------------------------------------------------------------------------- + +class StreamingSMA: + period: int + def __init__(self, period: int) -> None: ... + def update(self, value: float) -> float: ... + def reset(self) -> None: ... + +class StreamingEMA: + period: int + def __init__(self, period: int) -> None: ... + def update(self, value: float) -> float: ... + def reset(self) -> None: ... + +class StreamingRSI: + period: int + def __init__(self, period: int = 14) -> None: ... + def update(self, value: float) -> float: ... + def reset(self) -> None: ... + +class StreamingATR: + period: int + def __init__(self, period: int = 14) -> None: ... + def update(self, high: float, low: float, close: float) -> float: ... + def reset(self) -> None: ... + +class StreamingBBands: + period: int + def __init__( + self, + period: int = 20, + nbdevup: float = 2.0, + nbdevdn: float = 2.0, + ) -> None: ... + def update(self, value: float) -> tuple[float, float, float]: ... + def reset(self) -> None: ... + +class StreamingMACD: + def __init__( + self, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, + ) -> None: ... + def update(self, value: float) -> tuple[float, float, float]: ... + def reset(self) -> None: ... + +class StreamingStoch: + def __init__( + self, + fastk_period: int = 5, + slowk_period: int = 3, + slowd_period: int = 3, + ) -> None: ... + def update(self, high: float, low: float, close: float) -> tuple[float, float]: ... + def reset(self) -> None: ... + +class StreamingVWAP: + def __init__(self) -> None: ... + def update(self, high: float, low: float, close: float, volume: float) -> float: ... + def reset(self) -> None: ... + +class StreamingSupertrend: + period: int + def __init__(self, period: int = 7, multiplier: float = 3.0) -> None: ... + def update(self, high: float, low: float, close: float) -> tuple[float, int]: ... + def reset(self) -> None: ... + +# --------------------------------------------------------------------------- +# Candlestick Patterns +# --------------------------------------------------------------------------- + +def CDL2CROWS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3BLACKCROWS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3INSIDE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3LINESTRIKE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3OUTSIDE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3STARSINSOUTH( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3WHITESOLDIERS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLABANDONEDBABY( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLADVANCEBLOCK( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLBELTHOLD( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLBREAKAWAY( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLCLOSINGMARUBOZU( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLCONCEALBABYSWALL( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLCOUNTERATTACK( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLDARKCLOUDCOVER( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLDOJI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLDOJISTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLDRAGONFLYDOJI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLENGULFING( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLEVENINGDOJISTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLEVENINGSTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLGAPSIDESIDEWHITE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLGRAVESTONEDOJI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHAMMER( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHANGINGMAN( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHARAMI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHARAMICROSS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHIGHWAVE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHIKKAKE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHIKKAKEMOD( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHOMINGPIGEON( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLIDENTICAL3CROWS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLINNECK( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLINVERTEDHAMMER( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLKICKING( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLKICKINGBYLENGTH( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLLADDERBOTTOM( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLLONGLEGGEDDOJI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLLONGLINE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMARUBOZU( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMATCHINGLOW( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMATHOLD( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMORNINGDOJISTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMORNINGSTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLONNECK( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLPIERCING( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLRICKSHAWMAN( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLRISEFALL3METHODS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSEPARATINGLINES( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSHOOTINGSTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSHORTLINE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSPINNINGTOP( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSTALLEDPATTERN( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSTICKSANDWICH( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLTAKURI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLTASUKIGAP( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLTHRUSTING( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLTRISTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLUNIQUE3RIVER( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLUPSIDEGAP2CROWS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLXSIDEGAP3METHODS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... + +# --------------------------------------------------------------------------- +# Batch API +# --------------------------------------------------------------------------- + +from ferro_ta.batch import batch_apply as batch_apply +from ferro_ta.batch import batch_ema as batch_ema +from ferro_ta.batch import batch_rsi as batch_rsi +from ferro_ta.batch import batch_sma as batch_sma +from ferro_ta.batch import compute_many as compute_many + +# --------------------------------------------------------------------------- +# Exception hierarchy (re-exported from ferro_ta.exceptions) +# --------------------------------------------------------------------------- + +class FerroTAError(Exception): + code: str + suggestion: str | None + def __init__( + self, + message: str, + *, + code: str | None = None, + suggestion: str | None = None, + ) -> None: ... + +class FerroTAValueError(FerroTAError, ValueError): + code: str + suggestion: str | None + +class FerroTAInputError(FerroTAError, ValueError): + code: str + suggestion: str | None + +# --------------------------------------------------------------------------- +# API discovery (ferro_ta.api_info) +# --------------------------------------------------------------------------- + +def about() -> dict[str, Any]: ... +def indicators(category: str | None = None) -> list[dict[str, Any]]: ... +def info(func_or_name: Callable[..., Any] | str) -> dict[str, Any]: ... +def methods(category: str | None = None) -> list[dict[str, Any]]: ... + +# --------------------------------------------------------------------------- +# Logging utilities (ferro_ta.logging_utils) +# --------------------------------------------------------------------------- + +def get_logger() -> logging.Logger: ... +def enable_debug(fmt: str = ...) -> None: ... +def disable_debug() -> None: ... +def debug_mode(fmt: str = ...) -> AbstractContextManager[logging.Logger]: ... +def log_call(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: ... +def benchmark( + func: Callable[..., Any], + *args: Any, + n: int = 100, + warmup: int = 5, + **kwargs: Any, +) -> dict[str, float]: ... +def traced(func: _F) -> _F: ... diff --git a/ferro-ta-main/python/ferro_ta/_binding.py b/ferro-ta-main/python/ferro_ta/_binding.py new file mode 100644 index 0000000..9997c08 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/_binding.py @@ -0,0 +1,93 @@ +""" +Data-driven binding layer — generic wrapper for Rust indicator calls. + +This module provides a single helper that performs validation, array conversion +(_to_f64), Rust call, and error normalization. Indicator modules can use it to +reduce repetitive wrapper code; a manifest (see _indicator_manifest.yaml) describes +each indicator so that wrappers or code generation can be driven from data. + +Usage (manual wrapper): + from ferro_ta._binding import binding_call + def SMA(close, timeperiod=30): + return binding_call( + _sma, + array_params=["close"], + timeperiod_param="timeperiod", + close=close, + timeperiod=timeperiod, + ) + +Future: A code generator can read the manifest and emit either full wrapper +functions or binding_call(...) invocations so that ~6000 lines of repetitive +wrapper code are generated from the manifest. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional + +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import ( + _normalize_rust_error, + check_equal_length, + check_timeperiod, +) + + +def binding_call( + rust_fn: Callable[..., Any], + *, + array_params: list[str], + timeperiod_param: Optional[str] = None, + timeperiod_min: int = 1, + equal_length_groups: Optional[list[list[str]]] = None, + **kwargs: Any, +) -> Any: + """Call a Rust indicator with validation and array conversion. + + Parameters + ---------- + rust_fn : callable + The Rust function from _ferro_ta (e.g. _sma). + array_params : list of str + Names of keyword arguments that are array-like; they are converted + with _to_f64 and passed in order as positional args to rust_fn. + timeperiod_param : str, optional + If set, the value of kwargs[timeperiod_param] is validated with + check_timeperiod(..., minimum=timeperiod_min). + timeperiod_min : int + Minimum allowed value for timeperiod_param (default 1). + equal_length_groups : list of list of str, optional + Each inner list is a group of param names that must have equal length; + check_equal_length is called with that group. + **kwargs + Keyword arguments to pass. Array params are converted and passed + positionally; non-array params are passed as keyword arguments to + rust_fn (caller must ensure rust_fn signature matches). + + Returns + ------- + Result of rust_fn(...). Typically numpy.ndarray or tuple of ndarray. + + Raises + ------ + FerroTAValueError, FerroTAInputError + Via check_timeperiod / check_equal_length or _normalize_rust_error. + """ + if timeperiod_param is not None and timeperiod_param in kwargs: + check_timeperiod( + kwargs[timeperiod_param], + name=timeperiod_param, + minimum=timeperiod_min, + ) + if equal_length_groups is not None: + for group in equal_length_groups: + check_equal_length(**{k: kwargs[k] for k in group if k in kwargs}) + # Build positional args for rust_fn in array_params order, then remaining kwargs + pos_args = [_to_f64(kwargs[p]) for p in array_params if p in kwargs] + rest_kw = {k: v for k, v in kwargs.items() if k not in array_params} + try: + return rust_fn(*pos_args, **rest_kw) + except ValueError as e: + _normalize_rust_error(e) diff --git a/ferro-ta-main/python/ferro_ta/_indicator_manifest.yaml b/ferro-ta-main/python/ferro_ta/_indicator_manifest.yaml new file mode 100644 index 0000000..a08c0e8 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/_indicator_manifest.yaml @@ -0,0 +1,364 @@ +# Indicator binding manifest — data-driven description of Rust indicators. +# +# Used by scripts/generate_bindings.py to generate Python wrappers. +# Schema (per indicator): +# rust_fn: name of the function in _ferro_ta +# array_params: list of parameter names that are array-like (passed to _to_f64) +# timeperiod_param: optional; name of period parameter to validate +# timeperiod_min: optional; minimum value (default 1) +# equal_length_groups: optional; list of groups of param names that must have equal length +# defaults: optional; map of param name -> default value for function signature +# extra_params: optional; list of param names (after array_params and timeperiod) for signature/call +# +# Indicators with multiple period params or custom logic (MACD, MA, MAVP, MAMA, SAR, SAREXT, MACDEXT) +# are listed here for reference but are not generated (hand-written wrappers in overlap.py). + +overlap: + SMA: + rust_fn: sma + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + EMA: + rust_fn: ema + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + WMA: + rust_fn: wma + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + DEMA: + rust_fn: dema + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + TEMA: + rust_fn: tema + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + TRIMA: + rust_fn: trima + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + KAMA: + rust_fn: kama + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + T3: + rust_fn: t3 + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 5, vfactor: 0.7 } + extra_params: [vfactor] + BBANDS: + rust_fn: bbands + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 5, nbdevup: 2.0, nbdevdn: 2.0 } + extra_params: [nbdevup, nbdevdn] + MIDPOINT: + rust_fn: midpoint + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + MIDPRICE: + rust_fn: midprice + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + MACDFIX: + rust_fn: macdfix + array_params: [close] + timeperiod_param: signalperiod + defaults: { signalperiod: 9 } + # Below: documented in manifest but use hand-written wrappers (multiple periods or custom validation) + MACD: + rust_fn: macd + array_params: [close] + custom: true + SAR: + rust_fn: sar + array_params: [high, low] + custom: true + MA: + rust_fn: ma + array_params: [close] + custom: true + MAVP: + rust_fn: mavp + array_params: [close, periods] + custom: true + MAMA: + rust_fn: mama + array_params: [close] + custom: true + SAREXT: + rust_fn: sarext + array_params: [high, low] + custom: true + MACDEXT: + rust_fn: macdext + array_params: [close] + custom: true + +# --------------------------------------------------------------------------- +# volume +# --------------------------------------------------------------------------- +volume: + AD: + rust_fn: ad + array_params: [high, low, close, volume] + equal_length_groups: [[high, low, close, volume]] + ADOSC: + rust_fn: adosc + array_params: [high, low, close, volume] + equal_length_groups: [[high, low, close, volume]] + defaults: { fastperiod: 3, slowperiod: 10 } + extra_params: [fastperiod, slowperiod] + custom: true # two period params (fastperiod < slowperiod) + OBV: + rust_fn: obv + array_params: [close, volume] + equal_length_groups: [[close, volume]] + +# --------------------------------------------------------------------------- +# volatility +# --------------------------------------------------------------------------- +volatility: + ATR: + rust_fn: atr + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + NATR: + rust_fn: natr + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + TRANGE: + rust_fn: trange + array_params: [high, low, close] + equal_length_groups: [[high, low, close]] + +# --------------------------------------------------------------------------- +# statistic +# --------------------------------------------------------------------------- +statistic: + STDDEV: + rust_fn: stddev + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 5, nbdev: 1.0 } + extra_params: [nbdev] + VAR: + rust_fn: var + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 5, nbdev: 1.0 } + extra_params: [nbdev] + LINEARREG: + rust_fn: linearreg + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + LINEARREG_SLOPE: + rust_fn: linearreg_slope + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + LINEARREG_INTERCEPT: + rust_fn: linearreg_intercept + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + LINEARREG_ANGLE: + rust_fn: linearreg_angle + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + TSF: + rust_fn: tsf + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + BETA: + rust_fn: beta + array_params: [real0, real1] + timeperiod_param: timeperiod + equal_length_groups: [[real0, real1]] + defaults: { timeperiod: 5 } + CORREL: + rust_fn: correl + array_params: [real0, real1] + timeperiod_param: timeperiod + equal_length_groups: [[real0, real1]] + defaults: { timeperiod: 30 } + +# --------------------------------------------------------------------------- +# momentum (single-period or simple equal-length; multi-period / tuple-return marked custom) +# --------------------------------------------------------------------------- +momentum: + RSI: + rust_fn: rsi + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + MOM: + rust_fn: mom + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + ROC: + rust_fn: roc + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + ROCP: + rust_fn: rocp + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + ROCR: + rust_fn: rocr + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + ROCR100: + rust_fn: rocr100 + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + WILLR: + rust_fn: willr + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + AROON: + rust_fn: aroon + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + AROONOSC: + rust_fn: aroonosc + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + CCI: + rust_fn: cci + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + MFI: + rust_fn: mfi + array_params: [high, low, close, volume] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close, volume]] + defaults: { timeperiod: 14 } + BOP: + rust_fn: bop + array_params: [open, high, low, close] + equal_length_groups: [[open, high, low, close]] + STOCHF: + rust_fn: stochf + array_params: [high, low, close] + equal_length_groups: [[high, low, close]] + defaults: { fastk_period: 5, fastd_period: 3 } + extra_params: [fastk_period, fastd_period] + custom: true + STOCH: + rust_fn: stoch + array_params: [high, low, close] + equal_length_groups: [[high, low, close]] + defaults: { fastk_period: 5, slowk_period: 3, slowd_period: 3 } + extra_params: [fastk_period, slowk_period, slowd_period] + custom: true + STOCHRSI: + rust_fn: stochrsi + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14, fastk_period: 5, fastd_period: 3 } + extra_params: [fastk_period, fastd_period] + custom: true + APO: + rust_fn: apo + array_params: [close] + defaults: { fastperiod: 12, slowperiod: 26 } + extra_params: [fastperiod, slowperiod] + custom: true + PPO: + rust_fn: ppo + array_params: [close] + defaults: { fastperiod: 12, slowperiod: 26, signalperiod: 9 } + extra_params: [fastperiod, slowperiod, signalperiod] + custom: true + CMO: + rust_fn: cmo + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + PLUS_DM: + rust_fn: plus_dm + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + MINUS_DM: + rust_fn: minus_dm + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + PLUS_DI: + rust_fn: plus_di + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + MINUS_DI: + rust_fn: minus_di + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + DX: + rust_fn: dx + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + ADX: + rust_fn: adx + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + ADXR: + rust_fn: adxr + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + TRIX: + rust_fn: trix + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + ULTOSC: + rust_fn: ultosc + array_params: [high, low, close] + equal_length_groups: [[high, low, close]] + defaults: { timeperiod1: 7, timeperiod2: 14, timeperiod3: 28 } + extra_params: [timeperiod1, timeperiod2, timeperiod3] + custom: true diff --git a/ferro-ta-main/python/ferro_ta/_utils.py b/ferro-ta-main/python/ferro_ta/_utils.py new file mode 100644 index 0000000..296bc95 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/_utils.py @@ -0,0 +1,291 @@ +""" +Shared utility helpers for ferro_ta Python wrappers. +""" + +from __future__ import annotations + +import functools +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike + +# Default OHLCV column names for DataFrame contract +DEFAULT_OHLCV_COLUMNS = { + "open": "open", + "high": "high", + "low": "low", + "close": "close", + "volume": "volume", +} + + +@functools.lru_cache(maxsize=1) +def _optional_pandas_module(): + """Import pandas lazily once and cache absence for low-overhead hot paths.""" + try: + import pandas as pd + except ImportError: + return None + return pd + + +@functools.lru_cache(maxsize=1) +def _optional_polars_module(): + """Import polars lazily once and cache absence for low-overhead hot paths.""" + try: + import polars as pl + except ImportError: + return None + return pl + + +def _to_f64(data: ArrayLike) -> np.ndarray: + """Convert any array-like to a contiguous 1-D float64 NumPy array. + + Transparently accepts ``pandas.Series`` and ``polars.Series`` — the values + are extracted and the index/metadata is discarded (use :func:`pandas_wrap` + or :func:`polars_wrap` to preserve it). + + Fast path: if *data* is already a 1-D C-contiguous ``float64`` NumPy array + it is returned as-is without any copy or allocation. + """ + # Fast path: already a 1-D contiguous float64 numpy array — no copy needed. + if ( + isinstance(data, np.ndarray) + and data.dtype == np.float64 + and data.ndim == 1 + and data.flags["C_CONTIGUOUS"] + ): + return data + # Accept pandas Series/DataFrame without requiring pandas at import time + if hasattr(data, "to_numpy"): + try: + data = data.to_numpy(dtype=np.float64) # type: ignore[union-attr] + except TypeError: + # Some libraries (e.g. polars) have to_numpy() but don't accept dtype + data = np.asarray(data.to_numpy(), dtype=np.float64) # type: ignore[union-attr] + # Accept polars Series via to_numpy() (available since polars 0.13) + elif hasattr(data, "to_list") and type(data).__name__ == "Series": + # polars Series doesn't have to_numpy with dtype kwarg; use cast+to_numpy + try: + data = data.cast(float).to_numpy() # type: ignore[union-attr] + except Exception: + data = np.array(data.to_list(), dtype=np.float64) # type: ignore[union-attr] + arr = np.ascontiguousarray(data, dtype=np.float64) + if arr.ndim != 1: + from ferro_ta.core.exceptions import FerroTAInputError + + raise FerroTAInputError( + f"Input must be a 1-D array or list of prices, got {arr.ndim}-D array.", + suggestion="Flatten your array with .ravel() or pass a 1-D Series/list.", + ) + return arr + + +def get_ohlcv( + df: Any, + open_col: str = "open", + high_col: str = "high", + low_col: str = "low", + close_col: str = "close", + volume_col: Optional[str] = "volume", +) -> tuple[Any, Any, Any, Any, Any]: + """Extract OHLCV arrays or Series from a DataFrame with configurable column names. + + Use this when you have a single DataFrame with OHLCV columns (possibly with + different names) and want to call indicators that expect separate arrays. + Index is preserved when the input is a pandas DataFrame. + + Parameters + ---------- + df : pandas.DataFrame + DataFrame with at least columns for open, high, low, close (and optionally volume). + open_col, high_col, low_col, close_col, volume_col : str + Column names to use. Defaults are ``'open'``, ``'high'``, ``'low'``, + ``'close'``, ``'volume'``. + + Returns + ------- + tuple of (open, high, low, close, volume) + Each element is a 1-D array or pandas Series (same type as DataFrame columns) + with the same index as ``df``. Missing columns raise KeyError. + + Examples + -------- + >>> import pandas as pd + >>> from ferro_ta import ATR, RSI + >>> from ferro_ta._utils import get_ohlcv + >>> df = pd.DataFrame({ + ... 'Open': [1, 2, 3], 'High': [1.1, 2.1, 3.1], + ... 'Low': [0.9, 1.9, 2.9], 'Close': [1.05, 2.05, 3.05] + ... }) + >>> o, h, l, c, v = get_ohlcv(df, open_col='Open', high_col='High', + ... low_col='Low', close_col='Close', volume_col=None) + >>> atr = ATR(h, l, c, timeperiod=2) # index preserved if pandas + """ + try: + import pandas as pd + except ImportError: + raise ImportError("get_ohlcv requires pandas. Install with: pip install pandas") + + if not isinstance(df, pd.DataFrame): + raise TypeError("get_ohlcv expects a pandas.DataFrame") + + def _get(name: Optional[str]) -> Any: + if name is None: + return np.full(len(df), np.nan) + if name not in df.columns: + raise KeyError( + f"Column '{name}' not found in DataFrame. Columns: {list(df.columns)}" + ) + return df[name] + + vol_col = volume_col if (volume_col and volume_col in df.columns) else None + return ( + _get(open_col), + _get(high_col), + _get(low_col), + _get(close_col), + _get(vol_col) if vol_col else np.full(len(df), np.nan), + ) + + +def pandas_wrap(func): + """Decorator — transparent ``pandas.Series`` / ``DataFrame`` support. + + When at least one positional argument is a ``pandas.Series`` or + ``pandas.DataFrame`` column, the wrapper: + + 1. Extracts the NumPy arrays from all pandas inputs. + 2. Captures the index from the *first* pandas input. + 3. Calls the original function with plain NumPy arrays. + 4. Wraps every ``numpy.ndarray`` in the result back into a + ``pandas.Series`` (or tuple of Series) with the captured index. + + If ``pandas`` is not installed the decorator is a no-op pass-through so + the NumPy API is unaffected. + + Parameters that are already NumPy arrays (or lists) are passed through + unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always + passed through unchanged. + + Examples + -------- + >>> import pandas as pd, numpy as np + >>> from ferro_ta import SMA + >>> s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0]) + >>> result = SMA(s, timeperiod=3) + >>> isinstance(result, pd.Series) + True + >>> list(result.index) == list(s.index) + True + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + pd = _optional_pandas_module() + if pd is None: + return func(*args, **kwargs) + + pd_index = None + new_args: list[Any] = [] + + for arg in args: + if isinstance(arg, pd.Series): + if pd_index is None: + pd_index = arg.index + new_args.append(arg.to_numpy(dtype=np.float64)) + elif isinstance(arg, pd.DataFrame): + if pd_index is None: + pd_index = arg.index + # Pass each column as a 1-D array (single-column DataFrames) + if arg.shape[1] == 1: + new_args.append(arg.iloc[:, 0].to_numpy(dtype=np.float64)) + else: + new_args.append(arg) + else: + new_args.append(arg) + + result = func(*new_args, **kwargs) + + if pd_index is not None: + if isinstance(result, tuple): + return tuple( + pd.Series(r, index=pd_index) if isinstance(r, np.ndarray) else r + for r in result + ) + elif isinstance(result, np.ndarray): + return pd.Series(result, index=pd_index) + + return result + + # Mark so callers can detect wrapped functions + wrapper._pandas_wrapped = True # type: ignore[attr-defined] + return wrapper + + +def polars_wrap(func): + """Decorator — transparent ``polars.Series`` support. + + When at least one positional argument is a ``polars.Series``, the wrapper: + + 1. Converts all polars Series inputs to NumPy arrays. + 2. Captures the name from the *first* polars input (used as the result + series name). + 3. Calls the original function with plain NumPy arrays. + 4. Wraps every ``numpy.ndarray`` in the result back into a + ``polars.Series`` with the same name. + + If ``polars`` is not installed the decorator is a no-op pass-through so + the NumPy API is unaffected. + + Parameters that are already NumPy arrays (or lists) are passed through + unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always + passed through unchanged. + + Examples + -------- + >>> import polars as pl + >>> from ferro_ta import SMA + >>> s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0]) + >>> result = SMA(s, timeperiod=3) + >>> isinstance(result, pl.Series) + True + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + pl = _optional_polars_module() + if pl is None: + return func(*args, **kwargs) + + pl_name: Optional[str] = None + new_args: list[Any] = [] + + for arg in args: + if isinstance(arg, pl.Series): + if pl_name is None: + pl_name = arg.name + try: + new_args.append(arg.cast(pl.Float64).to_numpy()) + except Exception: + new_args.append(np.array(arg.to_list(), dtype=np.float64)) + else: + new_args.append(arg) + + result = func(*new_args, **kwargs) + + if pl_name is not None: + if isinstance(result, tuple): + return tuple( + pl.Series(pl_name, r) if isinstance(r, np.ndarray) else r + for r in result + ) + elif isinstance(result, np.ndarray): + return pl.Series(pl_name, result) + + return result + + wrapper._polars_wrapped = True # type: ignore[attr-defined] + return wrapper diff --git a/ferro-ta-main/python/ferro_ta/analysis/__init__.py b/ferro-ta-main/python/ferro_ta/analysis/__init__.py new file mode 100644 index 0000000..5b52d30 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/__init__.py @@ -0,0 +1,62 @@ +""" +ferro_ta.analysis — Portfolio analytics, strategy analysis, and financial modelling. + + +Sub-modules +----------- +* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics +* :mod:`ferro_ta.analysis.backtest` — Vectorised back-testing helpers +* :mod:`ferro_ta.analysis.regime` — Market regime detection +* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative-strength analysis +* :mod:`ferro_ta.analysis.attribution` — Return attribution +* :mod:`ferro_ta.analysis.signals` — Signal composition and screening +* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness helpers +* :mod:`ferro_ta.analysis.crypto` — Crypto-specific indicators and helpers +* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, and smile analytics +* :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/ferro-ta-main/python/ferro_ta/analysis/adjust.py b/ferro-ta-main/python/ferro_ta/analysis/adjust.py new file mode 100644 index 0000000..46f3afa --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/python/ferro_ta/analysis/attribution.py b/ferro-ta-main/python/ferro_ta/analysis/attribution.py new file mode 100644 index 0000000..f131bae --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/attribution.py @@ -0,0 +1,329 @@ +""" +ferro_ta.attribution — Performance attribution and trade analysis. +================================================================= + +Compute trade-level statistics and attribute equity-curve performance to +individual signals or time periods. Designed to work with the output of +``ferro_ta.backtest.backtest()``. + +Functions +--------- +trade_stats(pnl, hold_bars) + Compute win rate, avg win/loss, profit factor, and avg hold duration. + +from_backtest(result) + Extract the trade list (PnL per trade, hold duration) from a + :class:`~ferro_ta.backtest.BacktestResult`. + +attribution_by_month(bar_returns, timestamps) + Attribute per-bar returns to calendar months. + +attribution_by_signal(bar_returns, signal_labels) + Attribute per-bar returns to signal labels. + +TradeStats + Named-tuple-style result container returned by ``trade_stats``. + +Rust backend +------------ + ferro_ta._ferro_ta.trade_stats + ferro_ta._ferro_ta.monthly_contribution + ferro_ta._ferro_ta.signal_attribution +""" + +from __future__ import annotations + +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + extract_trades as _rust_extract_trades, +) +from ferro_ta._ferro_ta import ( + monthly_contribution as _rust_monthly_contribution, +) +from ferro_ta._ferro_ta import ( + signal_attribution as _rust_signal_attribution, +) +from ferro_ta._ferro_ta import ( + trade_stats as _rust_trade_stats, +) +from ferro_ta._utils import _to_f64 + +__all__ = [ + "TradeStats", + "trade_stats", + "from_backtest", + "attribution_by_month", + "attribution_by_signal", +] + + +# --------------------------------------------------------------------------- +# TradeStats container +# --------------------------------------------------------------------------- + + +class TradeStats: + """Container for trade-level statistics. + + Attributes + ---------- + win_rate : float — fraction of trades with PnL > 0 + avg_win : float — mean PnL of winning trades (0 if none) + avg_loss : float — mean PnL of losing trades (negative; 0 if none) + profit_factor : float — gross profit / |gross loss| (inf if no losses) + avg_hold_bars : float — mean hold duration in bars + n_trades : int — total number of trades + """ + + __slots__ = ( + "win_rate", + "avg_win", + "avg_loss", + "profit_factor", + "avg_hold_bars", + "n_trades", + ) + + def __init__( + self, + win_rate: float, + avg_win: float, + avg_loss: float, + profit_factor: float, + avg_hold_bars: float, + n_trades: int, + ) -> None: + self.win_rate = win_rate + self.avg_win = avg_win + self.avg_loss = avg_loss + self.profit_factor = profit_factor + self.avg_hold_bars = avg_hold_bars + self.n_trades = n_trades + + def __repr__(self) -> str: + return ( + f"TradeStats(n_trades={self.n_trades}, " + f"win_rate={self.win_rate:.2%}, " + f"profit_factor={self.profit_factor:.2f}, " + f"avg_hold={self.avg_hold_bars:.1f} bars)" + ) + + def to_dict(self) -> dict[str, Any]: + """Return stats as a plain dict.""" + return { + "n_trades": self.n_trades, + "win_rate": self.win_rate, + "avg_win": self.avg_win, + "avg_loss": self.avg_loss, + "profit_factor": self.profit_factor, + "avg_hold_bars": self.avg_hold_bars, + } + + +# --------------------------------------------------------------------------- +# trade_stats +# --------------------------------------------------------------------------- + + +def trade_stats( + pnl: ArrayLike, + hold_bars: Optional[ArrayLike] = None, +) -> TradeStats: + """Compute trade-level performance statistics. + + Parameters + ---------- + pnl : array-like — per-trade PnL (positive = win, negative = loss) + hold_bars : array-like, optional — hold duration in bars for each trade. + If ``None``, defaults to an array of ones (hold duration unknown). + + Returns + ------- + :class:`TradeStats` + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.attribution import trade_stats + >>> pnl = np.array([10.0, -5.0, 8.0, -3.0, 15.0, -2.0]) + >>> hold = np.array([5.0, 3.0, 7.0, 2.0, 10.0, 1.0]) + >>> ts = trade_stats(pnl, hold) + >>> print(ts) + TradeStats(n_trades=6, win_rate=50.00%, profit_factor=...) + """ + p = _to_f64(pnl) + n = len(p) + if n == 0: + raise ValueError("pnl must be non-empty") + if hold_bars is None: + h = np.ones(n, dtype=np.float64) + else: + h = _to_f64(hold_bars) + + win_rate, avg_win, avg_loss, profit_factor, avg_hold = _rust_trade_stats(p, h) + return TradeStats( + win_rate=win_rate, + avg_win=avg_win, + avg_loss=avg_loss, + profit_factor=profit_factor, + avg_hold_bars=avg_hold, + n_trades=n, + ) + + +# --------------------------------------------------------------------------- +# from_backtest +# --------------------------------------------------------------------------- + + +def from_backtest(result: Any) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Extract per-trade PnL and hold durations from a BacktestResult. + + Scans the ``positions`` and ``strategy_returns`` arrays of *result* to + find trade entries and exits, then computes per-trade PnL and duration. + + Parameters + ---------- + result : :class:`~ferro_ta.backtest.BacktestResult` + + Returns + ------- + tuple ``(pnl, hold_bars)`` — 1-D float64 arrays of length n_trades. + + Notes + ----- + A "trade" is defined as a continuous run of non-zero position. PnL is + the sum of ``strategy_returns`` during that period. Hold duration is + the number of bars in the run. + """ + pos = np.asarray(result.positions, dtype=np.float64) + ret = np.asarray(result.strategy_returns, dtype=np.float64) + pnl, hold = _rust_extract_trades(pos, ret) + return ( + np.asarray(pnl, dtype=np.float64), + np.asarray(hold, dtype=np.float64), + ) + + +# --------------------------------------------------------------------------- +# attribution_by_month +# --------------------------------------------------------------------------- + + +def attribution_by_month( + bar_returns: ArrayLike, + timestamps: Optional[ArrayLike] = None, +) -> dict[str, float]: + """Attribute per-bar returns to calendar months. + + Parameters + ---------- + bar_returns : array-like — per-bar strategy returns + timestamps : array-like of int64, optional — UTC timestamps in + nanoseconds (e.g. ``pandas.DatetimeIndex.astype('int64')``). + If ``None``, bars are grouped into calendar-agnostic monthly buckets + of 21 bars (approximate trading month). + + Returns + ------- + dict mapping month label (str ``'YYYY-MM'`` or ``'period_N'``) to + total return for that month. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.attribution import attribution_by_month + >>> rng = np.random.default_rng(0) + >>> ret = rng.normal(0, 0.01, 252) + >>> contrib = attribution_by_month(ret) + >>> list(contrib.keys())[:3] + ['period_0', 'period_1', 'period_2'] + """ + ret = _to_f64(bar_returns) + n = len(ret) + + if timestamps is not None: + # Convert ns timestamps → month index + ts = np.asarray(timestamps, dtype=np.int64) + # Month = year*12 + month_of_year (0-based) + # ns → seconds → datetime calculation (fast path without pandas) + try: + import pandas as pd + + dti = pd.to_datetime(ts, unit="ns", utc=True) + month_idx = (dti.year * 12 + dti.month - 1).astype(np.int64) # type: ignore[union-attr] + offset = int(month_idx[0]) + month_idx = (month_idx - offset).values.astype(np.int64) + except ImportError: + # Fallback: 21-bar buckets + month_idx = np.arange(n, dtype=np.int64) // 21 + else: + month_idx = np.arange(n, dtype=np.int64) // 21 + + months_arr, contrib_arr = _rust_monthly_contribution(ret, month_idx) + months = np.asarray(months_arr, dtype=np.int64) + contribs = np.asarray(contrib_arr, dtype=np.float64) + + if timestamps is not None: + try: + import pandas as pd + + ts = np.asarray(timestamps, dtype=np.int64) + dti = pd.to_datetime(ts, unit="ns", utc=True) + month_idx_full = (dti.year * 12 + dti.month - 1).astype(np.int64).values # type: ignore[union-attr] + offset = int(month_idx_full[0]) + labels = {} + for m, c in zip(months, contribs): + abs_month = int(m) + offset + year = abs_month // 12 + month_of_year = abs_month % 12 + 1 + labels[f"{year:04d}-{month_of_year:02d}"] = float(c) + return labels + except ImportError: + pass + + return {f"period_{int(m)}": float(c) for m, c in zip(months, contribs)} + + +# --------------------------------------------------------------------------- +# attribution_by_signal +# --------------------------------------------------------------------------- + + +def attribution_by_signal( + bar_returns: ArrayLike, + signal_labels: ArrayLike, +) -> dict[str, float]: + """Attribute per-bar returns to signal labels. + + Parameters + ---------- + bar_returns : array-like — per-bar strategy returns + signal_labels : array-like of int — signal label per bar. + Use ``-1`` for flat (no position) bars. + + Returns + ------- + dict mapping signal label (str) to total attributed return. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.attribution import attribution_by_signal + >>> rng = np.random.default_rng(0) + >>> ret = rng.normal(0, 0.01, 100) + >>> labels = np.where(np.arange(100) < 50, 0, 1) # signal 0 or signal 1 + >>> contrib = attribution_by_signal(ret, labels) + >>> sorted(contrib.keys()) + ['signal_0', 'signal_1'] + """ + ret = _to_f64(bar_returns) + lbl = np.asarray(signal_labels, dtype=np.int64) + labels_arr, contrib_arr = _rust_signal_attribution(ret, lbl) + labels = np.asarray(labels_arr, dtype=np.int64) + contribs = np.asarray(contrib_arr, dtype=np.float64) + return {f"signal_{int(lbl)}": float(c) for lbl, c in zip(labels, contribs)} diff --git a/ferro-ta-main/python/ferro_ta/analysis/backtest.py b/ferro-ta-main/python/ferro_ta/analysis/backtest.py new file mode 100644 index 0000000..4ce368a --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/backtest.py @@ -0,0 +1,1535 @@ +""" +Minimal Backtesting Harness +============================ + +A lightweight vectorized backtester that uses ferro_ta indicators as the engine. + +**Scope** (minimal harness): +- Vectorized approach: compute indicators once, then apply a signal function over bars. +- Single-asset, long-only or long/short, no leverage. +- Optional **commission** (per trade) and **slippage** (basis points) for more realistic equity. +- Returns a :class:`BacktestResult` with signals, positions, and equity curve. + +For production backtesting consider `backtrader`, `zipline`, or `vectorbt`. + +Quick start +----------- +>>> import numpy as np +>>> from ferro_ta.analysis.backtest import backtest, rsi_strategy +>>> +>>> # Generate synthetic OHLCV data +>>> np.random.seed(42) +>>> n = 100 +>>> close = np.cumprod(1 + np.random.randn(n) * 0.01) * 100 +>>> volume = np.random.randint(1_000, 10_000, n).astype(float) +>>> +>>> result = backtest(close, volume=volume, strategy="rsi_30_70") +>>> print(result) # BacktestResult(bars=100, trades=…, final_equity=…) + +API +--- +backtest(close, *, high=None, low=None, open=None, volume=None, + strategy="rsi_30_70", commission_per_trade=0, slippage_bps=0, **kwargs) + Run the backtester and return a :class:`BacktestResult`. Optional + commission (subtracted from equity on each position change) and slippage + (basis points; applied as a cost on the bar where position changes). + +rsi_strategy(close, timeperiod=14, oversold=30, overbought=70) + Built-in RSI oversold/overbought strategy; returns a signal array. + +sma_crossover_strategy(close, fast=10, slow=30) + Built-in SMA crossover strategy; returns a signal array. + +macd_crossover_strategy(close, fastperiod=12, slowperiod=26, signalperiod=9) + Built-in MACD line/signal crossover strategy; returns a signal array. + +BacktestResult + Dataclass-like container with signals, positions, returns, equity arrays. +""" + +from __future__ import annotations + +import dataclasses +import warnings +from collections import Counter +from collections.abc import Callable +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' + """ + effective: _RustCurrency = currency if currency is not None else INR + return effective.format(amount) + + +# --------------------------------------------------------------------------- +# BacktestResult +# --------------------------------------------------------------------------- + + +class BacktestResult: + """Container for backtesting output. + + Attributes + ---------- + signals : NDArray[np.float64] + Array of +1 (long), -1 (short), or 0 (flat) for every bar. + positions : NDArray[np.float64] + Lagged signals — position held *during* each bar (shift by 1 to + avoid look-ahead bias). + bar_returns : NDArray[np.float64] + Per-bar return of the underlying close price (pct change). + strategy_returns : NDArray[np.float64] + ``positions * bar_returns`` — strategy return at each bar. + equity : NDArray[np.float64] + Cumulative equity curve starting at 1.0. + n_trades : int + Number of position changes. + final_equity : float + Terminal equity value. + """ + + __slots__ = ( + "signals", + "positions", + "bar_returns", + "strategy_returns", + "equity", + "n_trades", + "final_equity", + ) + + def __init__( + self, + signals: NDArray[np.float64], + positions: NDArray[np.float64], + bar_returns: NDArray[np.float64], + strategy_returns: NDArray[np.float64], + equity: NDArray[np.float64], + ) -> None: + self.signals = signals + self.positions = positions + self.bar_returns = bar_returns + self.strategy_returns = strategy_returns + self.equity = equity + self.n_trades = int(np.sum(np.diff(positions) != 0)) + self.final_equity = float(equity[-1]) if len(equity) > 0 else 1.0 + + def __repr__(self) -> str: # pragma: no cover + return ( + f"BacktestResult(" + f"bars={len(self.signals)}, " + f"trades={self.n_trades}, " + f"final_equity={self.final_equity:.4f})" + ) + + +# --------------------------------------------------------------------------- +# Built-in strategies +# --------------------------------------------------------------------------- + + +def rsi_strategy( + close: ArrayLike, + timeperiod: int = 14, + oversold: float = 30.0, + overbought: float = 70.0, +) -> NDArray[np.float64]: + """RSI oversold / overbought signal generator. + + Returns + ------- + signals : ndarray of float64 + +1 where RSI <= oversold (buy signal), -1 where RSI >= overbought + (sell signal), 0 otherwise. NaN during the RSI warm-up period. + + Parameters + ---------- + close : array-like + Close prices. + timeperiod : int + RSI look-back period (default 14). + oversold : float + RSI level below which a long (+1) signal is generated (default 30). + overbought : float + RSI level above which a short (-1) signal is generated (default 70). + """ + if timeperiod < 1: + raise FerroTAValueError(f"timeperiod must be >= 1, got {timeperiod}") + + c = np.asarray(close, dtype=np.float64) + return np.asarray( + _rust_rsi_threshold_signals( + c, int(timeperiod), float(oversold), float(overbought) + ), + dtype=np.float64, + ) + + +def sma_crossover_strategy( + close: ArrayLike, + fast: int = 10, + slow: int = 30, +) -> NDArray[np.float64]: + """SMA fast/slow crossover strategy. + + Returns + ------- + signals : ndarray of float64 + +1 when fast SMA > slow SMA (uptrend), -1 when fast SMA < slow SMA + (downtrend), NaN during the warm-up window. + + Parameters + ---------- + close : array-like + Close prices. + fast : int + Fast SMA period (default 10). + slow : int + Slow SMA period (default 30). + """ + if fast < 1: + raise FerroTAValueError(f"fast must be >= 1, got {fast}") + if slow < 1: + raise FerroTAValueError(f"slow must be >= 1, got {slow}") + if fast >= slow: + raise FerroTAValueError(f"fast ({fast}) must be less than slow ({slow})") + + c = np.asarray(close, dtype=np.float64) + return np.asarray( + _rust_sma_crossover_signals(c, int(fast), int(slow)), + dtype=np.float64, + ) + + +def macd_crossover_strategy( + close: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, +) -> NDArray[np.float64]: + """MACD line / signal line crossover strategy. + + Returns + ------- + signals : ndarray of float64 + +1 when MACD line > signal line (uptrend), -1 when MACD line < signal line + (downtrend), NaN during the MACD warm-up window. + + Parameters + ---------- + close : array-like + Close prices. + fastperiod : int + Fast EMA period (default 12). + slowperiod : int + Slow EMA period (default 26). + signalperiod : int + Signal line EMA period (default 9). + """ + if fastperiod < 1 or slowperiod < 1 or signalperiod < 1: + raise FerroTAValueError("MACD periods must be >= 1") + if fastperiod >= slowperiod: + raise FerroTAValueError( + f"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})" + ) + + c = np.asarray(close, dtype=np.float64) + return np.asarray( + _rust_macd_crossover_signals( + c, int(fastperiod), int(slowperiod), int(signalperiod) + ), + dtype=np.float64, + ) + + +# --------------------------------------------------------------------------- +# 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, + "macd_crossover": macd_crossover_strategy, +} + + +# --------------------------------------------------------------------------- +# Main backtest entry point +# --------------------------------------------------------------------------- + + +def backtest( + close: ArrayLike, + *, + high: Optional[ArrayLike] = None, + low: Optional[ArrayLike] = None, + open: Optional[ArrayLike] = None, + volume: Optional[ArrayLike] = None, + strategy: Union[str, Callable[..., NDArray[np.float64]]] = "rsi_30_70", + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + **strategy_kwargs: object, +) -> BacktestResult: + """Run a vectorized backtest on *close* prices using *strategy*. + + Parameters + ---------- + close : array-like + Close prices (required). + high, low, open, volume : array-like, optional + Additional OHLCV data. Passed to the strategy function if it accepts + them (via ``**strategy_kwargs``); currently unused by the built-in + strategies. + strategy : str or callable + Either a name of a built-in strategy (``"rsi_30_70"``, + ``"sma_crossover"``, or ``"macd_crossover"``) or a callable with + signature ``(close, **kwargs) -> ndarray`` that returns a signal array. + commission_per_trade : float, optional + Fixed commission deducted from equity on each position change (default 0). + slippage_bps : float, optional + Slippage in basis points (1 bps = 0.01%) applied as a cost on the bar + where the position changes (default 0). + **strategy_kwargs + Extra keyword arguments forwarded to the strategy function + (e.g. ``timeperiod=14``, ``oversold=30``). + + Returns + ------- + BacktestResult + Container with signals, positions, equity curve, and trade count. + + Raises + ------ + FerroTAValueError + If a named strategy is unknown. + FerroTAInputError + If ``close`` is too short (< 2 bars) or contains non-finite values. + + Notes + ----- + Commission is subtracted from equity immediately after each position change. + Slippage is applied by reducing the strategy return on the bar where the + position changes by ``slippage_bps / 10000`` (one-way). + """ + 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)}.") + + # ------------------------------------------------------------------ + # 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, + commission_per_trade=float(commission_per_trade), + slippage_bps=float(slippage_bps), + ) + + return BacktestResult( + signals=signals, + positions=np.asarray(positions, dtype=np.float64), + bar_returns=np.asarray(bar_returns, dtype=np.float64), + 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=[ # type: ignore[arg-type] + "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/ferro-ta-main/python/ferro_ta/analysis/cross_asset.py b/ferro-ta-main/python/ferro_ta/analysis/cross_asset.py new file mode 100644 index 0000000..d79bd86 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/cross_asset.py @@ -0,0 +1,237 @@ +""" +ferro_ta.cross_asset — Cross-asset and relative strength analytics. + +Provides helpers for relative value and pair-trading workflows: +- relative_strength(asset_returns, benchmark_returns) +- spread(a, b, hedge=1.0) +- ratio(a, b) +- zscore(x, window) +- rolling_beta(a, b, window) + +Compute-intensive work delegates to Rust (via ferro_ta._ferro_ta). + +Functions +--------- +relative_strength(asset_returns, benchmark_returns) + Cumulative-return ratio (asset / benchmark), starting at 1. + +spread(a, b, hedge=1.0) + Spread series: a - hedge * b. + +ratio(a, b) + Ratio series: a / b. + +zscore(x, window) + Rolling Z-score of series *x* over a sliding window. + +rolling_beta(a, b, window) + Rolling beta (hedge ratio) of series *a* vs *b*. + +Rust backend +------------ + ferro_ta._ferro_ta.relative_strength + ferro_ta._ferro_ta.spread + ferro_ta._ferro_ta.zscore_series + ferro_ta._ferro_ta.rolling_beta +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ratio as _rust_ratio +from ferro_ta._ferro_ta import relative_strength as _rust_rel_strength +from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta +from ferro_ta._ferro_ta import spread as _rust_spread +from ferro_ta._ferro_ta import zscore_series as _rust_zscore +from ferro_ta._utils import _to_f64 + +__all__ = [ + "relative_strength", + "spread", + "ratio", + "zscore", + "rolling_beta", +] + + +# --------------------------------------------------------------------------- +# relative_strength +# --------------------------------------------------------------------------- + + +def relative_strength( + asset_returns: ArrayLike, + benchmark_returns: ArrayLike, +) -> NDArray[np.float64]: + """Compute relative strength of an asset versus a benchmark. + + Returns the ratio of cumulative returns:: + + RS[i] = (1 + r_asset[0]) * … * (1 + r_asset[i]) / + ((1 + r_bench[0]) * … * (1 + r_bench[i])) + + starting from RS[0] ≈ 1. + + Parameters + ---------- + asset_returns, benchmark_returns : array-like + Fractional returns per bar (e.g. 0.01 for +1%). Equal length. + + Returns + ------- + numpy.ndarray of same length — relative strength series. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import relative_strength + >>> r_a = np.array([0.01, 0.02, -0.01, 0.005]) + >>> r_b = np.array([0.005, 0.01, -0.005, 0.002]) + >>> rs = relative_strength(r_a, r_b) + >>> rs[0] > 1 # asset outperformed at bar 0 + True + """ + a = _to_f64(asset_returns) + b = _to_f64(benchmark_returns) + return _rust_rel_strength(a, b) + + +# --------------------------------------------------------------------------- +# spread +# --------------------------------------------------------------------------- + + +def spread( + a: ArrayLike, + b: ArrayLike, + hedge: float = 1.0, +) -> NDArray[np.float64]: + """Compute the spread between two series. + + ``spread[i] = a[i] - hedge * b[i]`` + + Parameters + ---------- + a, b : array-like (equal length) + hedge : float — hedge ratio (default 1.0) + + Returns + ------- + numpy.ndarray + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import spread + >>> a = np.array([10.0, 11.0, 12.0]) + >>> b = np.array([9.0, 10.0, 11.0]) + >>> list(spread(a, b)) + [1.0, 1.0, 1.0] + """ + return _rust_spread(_to_f64(a), _to_f64(b), float(hedge)) + + +# --------------------------------------------------------------------------- +# ratio +# --------------------------------------------------------------------------- + + +def ratio( + a: ArrayLike, + b: ArrayLike, +) -> NDArray[np.float64]: + """Compute the ratio of two series: a / b. + + Zeros in *b* produce ``NaN`` in the result. + + Parameters + ---------- + a, b : array-like (equal length) + + Returns + ------- + numpy.ndarray + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import ratio + >>> a = np.array([10.0, 12.0, 15.0]) + >>> b = np.array([5.0, 4.0, 5.0]) + >>> list(ratio(a, b)) + [2.0, 3.0, 3.0] + """ + return _rust_ratio(_to_f64(a), _to_f64(b)) + + +# --------------------------------------------------------------------------- +# zscore +# --------------------------------------------------------------------------- + + +def zscore( + x: ArrayLike, + window: int, +) -> NDArray[np.float64]: + """Compute the rolling Z-score of series *x*. + + ``z[i] = (x[i] - mean(x[i-window+1..i])) / std(x[i-window+1..i])`` + + Parameters + ---------- + x : array-like + window : int — must be >= 2 + + Returns + ------- + numpy.ndarray — NaN for first ``window-1`` positions. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import zscore + >>> x = np.array([1.0, 2.0, 3.0, 2.0, 1.0]) + >>> z = zscore(x, window=3) + >>> np.isnan(z[0]) and np.isnan(z[1]) + True + """ + return _rust_zscore(_to_f64(x), int(window)) + + +# --------------------------------------------------------------------------- +# rolling_beta +# --------------------------------------------------------------------------- + + +def rolling_beta( + a: ArrayLike, + b: ArrayLike, + window: int, +) -> NDArray[np.float64]: + """Compute rolling beta (hedge ratio) of series *a* vs *b*. + + Parameters + ---------- + a, b : array-like (equal length) + window : int — rolling window size (must be >= 2) + + Returns + ------- + numpy.ndarray — NaN for first ``window-1`` positions. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import rolling_beta + >>> rng = np.random.default_rng(42) + >>> b = rng.normal(0, 1, 50) + >>> a = 0.8 * b + rng.normal(0, 0.1, 50) + >>> rb = rolling_beta(a, b, window=20) + >>> np.isnan(rb[18]) + True + >>> abs(rb[-1] - 0.8) < 0.3 + True + """ + return _rust_rolling_beta(_to_f64(a), _to_f64(b), int(window)) diff --git a/ferro-ta-main/python/ferro_ta/analysis/crypto.py b/ferro-ta-main/python/ferro_ta/analysis/crypto.py new file mode 100644 index 0000000..0668371 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/crypto.py @@ -0,0 +1,232 @@ +""" +ferro_ta.crypto — Crypto and 24/7 market helpers. +================================================= + +Helpers designed for continuous (24/7) markets such as cryptocurrency or FX. + +Functions +--------- +funding_pnl(position_size, funding_rate) + Compute the cumulative PnL from periodic funding rate payments. + +continuous_bar_labels(n_bars, period_bars) + Assign integer period labels to bars without calendar-based sessions. + +session_boundaries(timestamps_ns) + Return bar indices at the start of each UTC-day session boundary. + +resample_continuous(ohlcv, period_bars) + Resample a continuous OHLCV series by grouping every *period_bars* input + bars into one output bar (no session filtering). + +Rust backend +------------ + ferro_ta._ferro_ta.funding_cumulative_pnl + ferro_ta._ferro_ta.continuous_bar_labels + ferro_ta._ferro_ta.mark_session_boundaries +""" + +from __future__ import annotations + +from typing import Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + continuous_bar_labels as _rust_continuous_bar_labels, +) +from ferro_ta._ferro_ta import ( + funding_cumulative_pnl as _rust_funding_cumulative_pnl, +) +from ferro_ta._ferro_ta import ( + mark_session_boundaries as _rust_mark_session_boundaries, +) +from ferro_ta._ferro_ta import ( + ohlcv_agg as _rust_ohlcv_agg, +) +from ferro_ta._utils import _to_f64 + +__all__ = [ + "funding_pnl", + "continuous_bar_labels", + "session_boundaries", + "resample_continuous", +] + +# type alias +OHLCVTuple = tuple[ + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], +] + + +def funding_pnl( + position_size: ArrayLike, + funding_rate: ArrayLike, +) -> NDArray[np.float64]: + """Compute cumulative PnL from periodic funding rate payments. + + Crypto perpetual contracts charge a periodic funding rate to position + holders. A long position pays when the funding rate is positive; a short + position receives. + + PnL at period *i* = ``-position_size[i] * funding_rate[i]`` + Returned array is the cumulative sum of those per-period PnLs. + + Parameters + ---------- + position_size : array-like — signed position size per funding period. + Positive = long, negative = short. + funding_rate : array-like — periodic funding rate in decimal notation + (e.g. 0.0001 = 0.01%). Must have the same length as *position_size*. + + Returns + ------- + numpy.ndarray of float64 — cumulative funding PnL. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.crypto import funding_pnl + >>> pos = np.ones(5) # long 1 contract + >>> rate = np.array([0.0001, 0.0002, -0.0001, 0.0001, 0.0001]) + >>> pnl = funding_pnl(pos, rate) + >>> pnl.round(6) + array([-0.0001, -0.0003, 0. , -0.0001, -0.0002]) + """ + return np.asarray( + _rust_funding_cumulative_pnl(_to_f64(position_size), _to_f64(funding_rate)), + dtype=np.float64, + ) + + +def continuous_bar_labels( + n_bars: int, + period_bars: int, +) -> NDArray[np.int64]: + """Assign sequential integer labels to bars in equal-size buckets. + + Useful for grouping continuous data (no session gaps) into periods without + relying on calendar logic. Bars 0…(period_bars-1) get label 0, + bars period_bars…(2·period_bars-1) get label 1, etc. + + Parameters + ---------- + n_bars : int — total number of bars + period_bars : int — number of bars per period (e.g. 24 for hourly → daily) + + Returns + ------- + numpy.ndarray of int64 — period label per bar. + + Examples + -------- + >>> from ferro_ta.analysis.crypto import continuous_bar_labels + >>> continuous_bar_labels(10, 3) + array([0, 0, 0, 1, 1, 1, 2, 2, 2, 3]) + """ + return np.asarray( + _rust_continuous_bar_labels(int(n_bars), int(period_bars)), + dtype=np.int64, + ) + + +def session_boundaries( + timestamps_ns: ArrayLike, +) -> NDArray[np.int64]: + """Return bar indices at the start of each UTC-day boundary. + + Intended for 24/7 data where no exchange session gaps exist. Useful for + building daily OHLCV bars from intraday continuous data. + + Parameters + ---------- + timestamps_ns : array-like of int64 — UTC timestamps in nanoseconds + (e.g. ``pandas.DatetimeIndex.astype('int64')``). + + Returns + ------- + numpy.ndarray of int64 — indices of the first bar in each UTC day + (always includes index 0). + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.crypto import session_boundaries + >>> # Two UTC days of hourly bars: day 0 = bars 0-23, day 1 = bars 24-47 + >>> base_ns = np.int64(1_700_000_000_000_000_000) # some UTC timestamp + >>> ns_per_hour = np.int64(3_600_000_000_000) + >>> ts = base_ns + np.arange(48, dtype=np.int64) * ns_per_hour + >>> bounds = session_boundaries(ts) + """ + ts = np.asarray(timestamps_ns, dtype=np.int64) + return np.asarray( + _rust_mark_session_boundaries(ts), + dtype=np.int64, + ) + + +def resample_continuous( + ohlcv: Union[ + tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike], + object, # pandas.DataFrame + ], + period_bars: int, +) -> OHLCVTuple: + """Resample a continuous OHLCV series by grouping *period_bars* input bars. + + Unlike time-based resampling, this function requires no calendar or + session information. Every *period_bars* consecutive input bars are + aggregated into one output bar. Ideal for 24/7 crypto data. + + Parameters + ---------- + ohlcv : tuple ``(open, high, low, close, volume)`` of array-like, + **or** a ``pandas.DataFrame`` with columns ``open/high/low/close/volume`` + (case-insensitive). + period_bars : int — number of input bars per output bar (must be >= 1). + + Returns + ------- + tuple ``(open, high, low, close, volume)`` of numpy.ndarray — resampled bars. + + Notes + ----- + The last output bar may aggregate fewer than *period_bars* input bars if + ``len(close) % period_bars != 0``. + """ + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame): + cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr] + o = _to_f64(ohlcv[cols["open"]].values) # type: ignore[index] + h = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index] + lo = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index] + c = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index] + v = _to_f64(ohlcv[cols["volume"]].values) # type: ignore[index] + else: + o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr] + except ImportError: + o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr] + + n = len(c) + if period_bars < 1: + raise ValueError("period_bars must be >= 1") + # Build bar-group labels + labels = np.asarray( + _rust_continuous_bar_labels(n, int(period_bars)), + dtype=np.int64, + ) + ro, rh, rl, rc, rv = _rust_ohlcv_agg(o, h, lo, c, v, labels) + return ( + np.asarray(ro, dtype=np.float64), + np.asarray(rh, dtype=np.float64), + np.asarray(rl, dtype=np.float64), + np.asarray(rc, dtype=np.float64), + np.asarray(rv, dtype=np.float64), + ) diff --git a/ferro-ta-main/python/ferro_ta/analysis/derivatives_payoff.py b/ferro-ta-main/python/ferro_ta/analysis/derivatives_payoff.py new file mode 100644 index 0000000..0d3ff26 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/derivatives_payoff.py @@ -0,0 +1,357 @@ +""" +ferro_ta.analysis.derivatives_payoff — Multi-leg payoff and Greeks aggregation. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import aggregate_greeks_legs as _rust_aggregate_greeks_legs +from ferro_ta._ferro_ta import strategy_payoff_dense as _rust_strategy_payoff_dense +from ferro_ta._ferro_ta import strategy_payoff_legs as _rust_strategy_payoff_legs +from ferro_ta._ferro_ta import strategy_value_dense as _rust_strategy_value_dense +from ferro_ta.analysis.options import OptionGreeks +from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg +from ferro_ta.core.exceptions import ( + FerroTAInputError, + FerroTAValueError, + _normalize_rust_error, +) + +__all__ = [ + "PayoffLeg", + "option_leg_payoff", + "futures_leg_payoff", + "stock_leg_payoff", + "strategy_payoff", + "strategy_value", + "aggregate_greeks", +] + + +@dataclass(frozen=True) +class PayoffLeg: + instrument: str + side: str + quantity: float = 1.0 + option_type: str | None = None + strike: float | None = None + premium: float = 0.0 + entry_price: float | None = None + volatility: float | None = None + time_to_expiry: float | None = None + rate: float = 0.0 + carry: float = 0.0 + multiplier: float = 1.0 + + def __post_init__(self) -> None: + if self.instrument not in {"option", "future", "stock"}: + raise FerroTAValueError( + "instrument must be 'option', 'future', or 'stock'." + ) + if self.side not in {"long", "short"}: + raise FerroTAValueError("side must be 'long' or 'short'.") + if self.instrument == "option": + if self.option_type not in {"call", "put"}: + raise FerroTAValueError( + "option legs require option_type='call' or 'put'." + ) + if self.strike is None: + raise FerroTAValueError("option legs require strike.") + if self.instrument in {"future", "stock"} and self.entry_price is None: + raise FerroTAValueError(f"{self.instrument} legs require entry_price.") + + +def _side_sign(side: str) -> float: + return 1.0 if side == "long" else -1.0 + + +def _coerce_spot_grid(spot_grid: ArrayLike) -> NDArray[np.float64]: + grid = np.asarray(spot_grid, dtype=np.float64) + if grid.ndim != 1: + raise FerroTAInputError("spot_grid must be a 1-D array.") + return np.ascontiguousarray(grid) + + +def option_leg_payoff( + spot_grid: ArrayLike, + *, + strike: float, + premium: float = 0.0, + option_type: str = "call", + side: str = "long", + quantity: float = 1.0, + multiplier: float = 1.0, +) -> NDArray[np.float64]: + """Expiry payoff for a single option leg.""" + grid = _coerce_spot_grid(spot_grid) + _side_sign(side) + if option_type not in {"call", "put"}: + raise FerroTAValueError("option_type must be 'call' or 'put'.") + return np.asarray( + _rust_strategy_payoff_dense( + grid, + np.array([0], dtype=np.int64), # option + np.array([1 if side == "long" else -1], dtype=np.int64), + np.array([1 if option_type == "call" else -1], dtype=np.int64), + np.array([float(strike)], dtype=np.float64), + np.array([float(premium)], dtype=np.float64), + np.array([0.0], dtype=np.float64), + np.array([float(quantity)], dtype=np.float64), + np.array([float(multiplier)], dtype=np.float64), + ), + dtype=np.float64, + ) + + +def futures_leg_payoff( + spot_grid: ArrayLike, + *, + entry_price: float, + side: str = "long", + quantity: float = 1.0, + multiplier: float = 1.0, +) -> NDArray[np.float64]: + """P/L profile for a futures leg.""" + grid = _coerce_spot_grid(spot_grid) + _side_sign(side) + return np.asarray( + _rust_strategy_payoff_dense( + grid, + np.array([1], dtype=np.int64), # future + np.array([1 if side == "long" else -1], dtype=np.int64), + np.array([-1], dtype=np.int64), + np.array([0.0], dtype=np.float64), + np.array([0.0], dtype=np.float64), + np.array([float(entry_price)], dtype=np.float64), + np.array([float(quantity)], dtype=np.float64), + np.array([float(multiplier)], dtype=np.float64), + ), + dtype=np.float64, + ) + + +def stock_leg_payoff( + spot_grid: ArrayLike, + *, + entry_price: float, + side: str = "long", + quantity: float = 1.0, + multiplier: float = 1.0, +) -> NDArray[np.float64]: + """P/L profile for a single stock (equity) leg over a spot grid. + + Payoff is linear:: + + P/L = sign(side) × quantity × multiplier × (spot − entry_price) + + Mathematically equivalent to a futures leg — no optionality. Use this + leg type when modelling strategies that hold the underlying equity: + Covered Call, Protective Put, Collar, Covered Strangle, etc. + + Parameters + ---------- + spot_grid: + 1-D array of spot prices at which to evaluate the P/L. + entry_price: + Purchase (or short-sale) price of the stock. + side: + ``"long"`` (default) or ``"short"``. + quantity: + Number of shares / contracts (default 1). + multiplier: + Contract multiplier (default 1.0). + + Returns + ------- + NDArray[float64] + P/L at each grid point, same shape as *spot_grid*. + """ + grid = _coerce_spot_grid(spot_grid) + _side_sign(side) + return np.asarray( + _rust_strategy_payoff_dense( + grid, + np.array([2], dtype=np.int64), # stock + np.array([1 if side == "long" else -1], dtype=np.int64), + np.array([-1], dtype=np.int64), + np.array([0.0], dtype=np.float64), + np.array([0.0], dtype=np.float64), + np.array([float(entry_price)], dtype=np.float64), + np.array([float(quantity)], dtype=np.float64), + np.array([float(multiplier)], dtype=np.float64), + ), + dtype=np.float64, + ) + + +def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg: + return PayoffLeg(**mapping) + + +def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg: + return PayoffLeg( + instrument=leg.instrument, + side=leg.side, + quantity=float(leg.quantity), + option_type=leg.option_type, + strike=leg.strike_selector.explicit_strike + if leg.strike_selector is not None + else None, + ) + + +def _normalize_legs( + legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None, + *, + strategy: DerivativesStrategy | None = None, +) -> tuple[PayoffLeg, ...]: + if strategy is not None: + return tuple(_strategy_leg_to_payoff_leg(leg) for leg in strategy.legs) + if legs is None: + raise FerroTAInputError("Provide either legs or strategy.") + normalized: list[PayoffLeg] = [] + for leg in legs: + normalized.append(leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg)) + return tuple(normalized) + + +def strategy_payoff( + spot_grid: ArrayLike, + *, + legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None, + strategy: DerivativesStrategy | None = None, +) -> NDArray[np.float64]: + """Aggregate expiry payoff across option and futures legs.""" + grid = _coerce_spot_grid(spot_grid) + normalized = _normalize_legs(legs, strategy=strategy) + if len(normalized) == 0: + return np.zeros_like(grid) + + try: + return np.asarray( + _rust_strategy_payoff_legs(grid, normalized), dtype=np.float64 + ) + except ValueError as err: + _normalize_rust_error(err) + + +def aggregate_greeks( + spot: float, + *, + legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None, + strategy: DerivativesStrategy | None = None, +) -> OptionGreeks: + """Aggregate Greeks across option and futures legs.""" + normalized = _normalize_legs(legs, strategy=strategy) + if len(normalized) == 0: + return OptionGreeks(0.0, 0.0, 0.0, 0.0, 0.0) + + try: + delta, gamma, vega, theta, rho = _rust_aggregate_greeks_legs( + float(spot), normalized + ) + except ValueError as err: + _normalize_rust_error(err) + + return OptionGreeks( + float(delta), + float(gamma), + float(vega), + float(theta), + float(rho), + ) + + +def strategy_value( + spot_grid: ArrayLike, + *, + legs: Sequence[PayoffLeg | Mapping[str, Any]], + time_to_expiry: float, + volatility: float, + rate: float = 0.0, + carry: float = 0.0, +) -> NDArray[np.float64]: + """Current BSM mid-price value of a multi-leg strategy over a spot grid. + + Unlike :func:`strategy_payoff` (which computes intrinsic value at expiry), + this uses live BSM pricing for option legs so the result reflects the + pre-expiry value including time value. + + Parameters + ---------- + spot_grid: + Array of spot prices to evaluate. + legs: + Sequence of :class:`PayoffLeg` (or dicts). Option legs must have + ``strike`` and ``premium`` set; future/stock legs must have + ``entry_price`` set. + time_to_expiry: + Shared time-to-expiry (years) applied to all option legs. + volatility: + Shared implied vol applied to all option legs. + rate: + Risk-free rate applied to all legs. + carry: + Carry / dividend yield applied to all option legs. + """ + grid = _coerce_spot_grid(spot_grid) + normalized: tuple[PayoffLeg, ...] = tuple( + leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg) for leg in legs + ) + if len(normalized) == 0: + return np.zeros_like(grid) + + n_legs = len(normalized) + instruments = np.empty(n_legs, dtype=np.int64) + sides = np.empty(n_legs, dtype=np.int64) + option_types = np.empty(n_legs, dtype=np.int64) + strikes = np.zeros(n_legs, dtype=np.float64) + premiums = np.zeros(n_legs, dtype=np.float64) + entry_prices = np.zeros(n_legs, dtype=np.float64) + quantities = np.ones(n_legs, dtype=np.float64) + multipliers = np.ones(n_legs, dtype=np.float64) + ttes = np.full(n_legs, time_to_expiry, dtype=np.float64) + vols = np.full(n_legs, volatility, dtype=np.float64) + rates = np.full(n_legs, rate, dtype=np.float64) + carries = np.full(n_legs, carry, dtype=np.float64) + + _inst_map = {"option": 0, "future": 1, "stock": 2} + for i, leg in enumerate(normalized): + instruments[i] = _inst_map[leg.instrument] + sides[i] = 1 if leg.side == "long" else -1 + option_types[i] = 1 if leg.option_type == "call" else -1 + if leg.strike is not None: + strikes[i] = float(leg.strike) + premiums[i] = float(leg.premium) + if leg.entry_price is not None: + entry_prices[i] = float(leg.entry_price) + quantities[i] = float(leg.quantity) + multipliers[i] = float(leg.multiplier) + + try: + return np.asarray( + _rust_strategy_value_dense( + grid, + instruments, + sides, + option_types, + strikes, + premiums, + entry_prices, + quantities, + multipliers, + ttes, + vols, + rates, + carries, + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) diff --git a/ferro-ta-main/python/ferro_ta/analysis/features.py b/ferro-ta-main/python/ferro_ta/analysis/features.py new file mode 100644 index 0000000..e0bad2a --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/features.py @@ -0,0 +1,184 @@ +""" +ferro_ta.features — Feature matrix and ML readiness. + +Exports a feature matrix (indicators as columns, bars as rows) suitable for +sklearn or other ML pipelines. + +Functions +--------- +feature_matrix(ohlcv, indicators, *, nan_policy='keep', close_col='close', ...) + Compute all requested indicators on the OHLCV data and return a single + DataFrame with bars as rows and indicator names as columns. + +Rust backend +------------ +Individual indicator calls delegate to existing Rust-backed ferro_ta functions +via the registry. +""" + +from __future__ import annotations + +from typing import Any, Optional, Union + +import numpy as np +from numpy.typing import NDArray + +from ferro_ta._ferro_ta import forward_fill_nan as _rust_forward_fill_nan +from ferro_ta._utils import _to_f64 +from ferro_ta.data.batch import compute_many + +__all__ = [ + "feature_matrix", +] + + +def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]: + return np.asarray( + _rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64)) + ) + + +# --------------------------------------------------------------------------- +# feature_matrix +# --------------------------------------------------------------------------- + + +def feature_matrix( + ohlcv: Any, + indicators: list[Union[str, tuple[str, dict[str, Any]]]], + *, + nan_policy: str = "keep", + close_col: str = "close", + high_col: str = "high", + low_col: str = "low", + open_col: str = "open", + volume_col: str = "volume", +) -> Any: + """Compute multiple indicators on OHLCV data and return a feature matrix. + + Parameters + ---------- + ohlcv : pandas.DataFrame or dict of arrays + OHLCV data. Must contain at least a ``close`` column/key. + indicators : list of (str | tuple) + Each element is either: + - A string indicator name (e.g. ``'RSI'``), using default params. + - A ``(name, kwargs)`` tuple, e.g. ``('RSI', {'timeperiod': 14})``. + - A ``(name, kwargs, output_key)`` 3-tuple to name a specific output + of a multi-output indicator (0-indexed int or output key). + + The column name in the output matrix is ```` for single-output + indicators or ``_`` for multi-output ones. + + nan_policy : str + How to handle NaN values (warmup rows): + - ``'keep'`` (default) — keep NaN rows as-is. + - ``'drop'`` — drop any row that contains at least one NaN. + - ``'fill'`` — forward-fill NaN values. + + close_col, high_col, low_col, open_col, volume_col : str + Column names when *ohlcv* is a DataFrame. + + Returns + ------- + pandas.DataFrame or dict of numpy arrays + If pandas is available, returns a DataFrame with one column per + indicator. Otherwise returns a dict {name: array}. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.features import feature_matrix + >>> rng = np.random.default_rng(0) + >>> n = 50 + >>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100 + >>> ohlcv = {"close": close, "high": close * 1.01, "low": close * 0.99, + ... "open": close, "volume": np.ones(n) * 1000} + >>> fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10}), + ... ("RSI", {"timeperiod": 14})]) + >>> list(fm.keys()) + ['SMA', 'RSI'] + """ + + # --- Extract arrays --- + def _get(col: str) -> Optional[NDArray[np.float64]]: + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame): + return _to_f64(ohlcv[col].to_numpy()) if col in ohlcv.columns else None + except ImportError: + pass + if isinstance(ohlcv, dict): + return _to_f64(ohlcv[col]) if col in ohlcv else None + return None + + close = _get(close_col) + high = _get(high_col) + low = _get(low_col) + _open = _get(open_col) # noqa: F841 - reserved for future OHLCV indicators + volume = _get(volume_col) + + if close is None: + raise ValueError(f"close column '{close_col}' not found in ohlcv") + + n = len(close) + columns: dict[str, NDArray[np.float64]] = {} + + results = compute_many( + indicators, + close=close, + high=high if high is not None else None, + low=low if low is not None else None, + volume=volume if volume is not None else None, + ) + + for spec, result in zip(indicators, results): + if isinstance(spec, str): + name = spec + out_key: Optional[Any] = None + elif len(spec) == 2: + name, _ = spec # type: ignore[misc] + out_key = None + else: + name, _, out_key = spec # type: ignore[misc] + + if isinstance(result, tuple): + if out_key is not None: + if isinstance(out_key, int): + col_name = f"{name}_{out_key}" + columns[col_name] = np.asarray(result[out_key], dtype=np.float64) + else: + col_name = f"{name}_{out_key}" + columns[col_name] = np.asarray( + result[int(out_key)], dtype=np.float64 + ) + else: + for ki, arr in enumerate(result): + columns[f"{name}_{ki}"] = np.asarray(arr, dtype=np.float64) + else: + columns[name] = np.asarray(result, dtype=np.float64) + + # --- NaN policy --- + try: + import pandas as pd + + index = None + if isinstance(ohlcv, pd.DataFrame): + index = ohlcv.index + df = pd.DataFrame(columns, index=index) + if nan_policy == "drop": + df = df.dropna() + elif nan_policy == "fill": + df = df.ffill() + return df + except ImportError: + if nan_policy == "drop": + mask = np.ones(n, dtype=bool) + for arr in columns.values(): + mask &= ~np.isnan(arr) + return {k: v[mask] for k, v in columns.items()} + elif nan_policy == "fill": + for key, arr in columns.items(): + columns[key] = _forward_fill_nan(arr) + return columns diff --git a/ferro-ta-main/python/ferro_ta/analysis/futures.py b/ferro-ta-main/python/ferro_ta/analysis/futures.py new file mode 100644 index 0000000..6b38643 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/futures.py @@ -0,0 +1,230 @@ +""" +ferro_ta.analysis.futures — Futures and forward-curve analytics. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import annualized_basis as _rust_annualized_basis +from ferro_ta._ferro_ta import ( + back_adjusted_continuous_contract as _rust_back_adjusted, +) +from ferro_ta._ferro_ta import calendar_spreads as _rust_calendar_spreads +from ferro_ta._ferro_ta import carry_spread as _rust_carry_spread +from ferro_ta._ferro_ta import curve_slope as _rust_curve_slope +from ferro_ta._ferro_ta import curve_summary as _rust_curve_summary +from ferro_ta._ferro_ta import futures_basis as _rust_basis +from ferro_ta._ferro_ta import implied_carry_rate as _rust_implied_carry_rate +from ferro_ta._ferro_ta import parity_gap as _rust_parity_gap +from ferro_ta._ferro_ta import ( + ratio_adjusted_continuous_contract as _rust_ratio_adjusted, +) +from ferro_ta._ferro_ta import roll_yield as _rust_roll_yield +from ferro_ta._ferro_ta import synthetic_forward as _rust_synthetic_forward +from ferro_ta._ferro_ta import synthetic_spot as _rust_synthetic_spot +from ferro_ta._ferro_ta import weighted_continuous_contract as _rust_weighted +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + +__all__ = [ + "CurveSummary", + "synthetic_forward", + "synthetic_spot", + "parity_gap", + "basis", + "annualized_basis", + "implied_carry_rate", + "carry_spread", + "weighted_continuous_contract", + "back_adjusted_continuous_contract", + "ratio_adjusted_continuous_contract", + "roll_yield", + "calendar_spreads", + "curve_slope", + "curve_summary", +] + + +@dataclass(frozen=True) +class CurveSummary: + front_basis: float + average_basis: float + slope: float + is_contango: bool + + def to_dict(self) -> dict[str, float | bool]: + return { + "front_basis": self.front_basis, + "average_basis": self.average_basis, + "slope": self.slope, + "is_contango": self.is_contango, + } + + +def synthetic_forward( + call_price: float, + put_price: float, + strike: float, + rate: float, + time_to_expiry: float, +) -> float: + return float( + _rust_synthetic_forward( + float(call_price), + float(put_price), + float(strike), + float(rate), + float(time_to_expiry), + ) + ) + + +def synthetic_spot( + call_price: float, + put_price: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + carry: float = 0.0, +) -> float: + return float( + _rust_synthetic_spot( + float(call_price), + float(put_price), + float(strike), + float(rate), + float(time_to_expiry), + float(carry), + ) + ) + + +def parity_gap( + call_price: float, + put_price: float, + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + carry: float = 0.0, +) -> float: + return float( + _rust_parity_gap( + float(call_price), + float(put_price), + float(spot), + float(strike), + float(rate), + float(time_to_expiry), + float(carry), + ) + ) + + +def basis(spot: float, future: float) -> float: + return float(_rust_basis(float(spot), float(future))) + + +def annualized_basis(spot: float, future: float, time_to_expiry: float) -> float: + return float( + _rust_annualized_basis(float(spot), float(future), float(time_to_expiry)) + ) + + +def implied_carry_rate(spot: float, future: float, time_to_expiry: float) -> float: + return float( + _rust_implied_carry_rate(float(spot), float(future), float(time_to_expiry)) + ) + + +def carry_spread( + spot: float, future: float, rate: float, time_to_expiry: float +) -> float: + return float( + _rust_carry_spread( + float(spot), float(future), float(rate), float(time_to_expiry) + ) + ) + + +def weighted_continuous_contract( + front: ArrayLike, + next_contract: ArrayLike, + next_weights: ArrayLike, +) -> NDArray[np.float64]: + try: + return np.asarray( + _rust_weighted( + _to_f64(front), _to_f64(next_contract), _to_f64(next_weights) + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def back_adjusted_continuous_contract( + front: ArrayLike, + next_contract: ArrayLike, + next_weights: ArrayLike, +) -> NDArray[np.float64]: + try: + return np.asarray( + _rust_back_adjusted( + _to_f64(front), _to_f64(next_contract), _to_f64(next_weights) + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def ratio_adjusted_continuous_contract( + front: ArrayLike, + next_contract: ArrayLike, + next_weights: ArrayLike, +) -> NDArray[np.float64]: + try: + return np.asarray( + _rust_ratio_adjusted( + _to_f64(front), _to_f64(next_contract), _to_f64(next_weights) + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def roll_yield(front_price: float, next_price: float, time_to_expiry: float) -> float: + return float( + _rust_roll_yield(float(front_price), float(next_price), float(time_to_expiry)) + ) + + +def calendar_spreads(futures_prices: ArrayLike) -> NDArray[np.float64]: + return np.asarray(_rust_calendar_spreads(_to_f64(futures_prices)), dtype=np.float64) + + +def curve_slope(tenors: ArrayLike, futures_prices: ArrayLike) -> float: + try: + return float(_rust_curve_slope(_to_f64(tenors), _to_f64(futures_prices))) + except ValueError as err: + _normalize_rust_error(err) + + +def curve_summary( + spot: float, tenors: ArrayLike, futures_prices: ArrayLike +) -> CurveSummary: + try: + front_basis, average_basis, slope, is_contango = _rust_curve_summary( + float(spot), _to_f64(tenors), _to_f64(futures_prices) + ) + except ValueError as err: + _normalize_rust_error(err) + return CurveSummary(front_basis, average_basis, slope, is_contango) diff --git a/ferro-ta-main/python/ferro_ta/analysis/live.py b/ferro-ta-main/python/ferro_ta/analysis/live.py new file mode 100644 index 0000000..8ad9b58 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/python/ferro_ta/analysis/multitf.py b/ferro-ta-main/python/ferro_ta/analysis/multitf.py new file mode 100644 index 0000000..2f2d46c --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/python/ferro_ta/analysis/optimize.py b/ferro-ta-main/python/ferro_ta/analysis/optimize.py new file mode 100644 index 0000000..1981c1e --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/python/ferro_ta/analysis/options.py b/ferro-ta-main/python/ferro_ta/analysis/options.py new file mode 100644 index 0000000..5920e8b --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/options.py @@ -0,0 +1,1595 @@ +""" +ferro_ta.analysis.options — Rust-backed derivatives analytics for options. + +This module preserves the legacy IV-series helpers and expands them with +pricing, Greeks, implied-volatility inversion, smile analytics, and strike +selection helpers suitable for research and simulation workflows. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + black76_price as _rust_black76_price, +) +from ferro_ta._ferro_ta import ( + black76_price_batch as _rust_black76_price_batch, +) +from ferro_ta._ferro_ta import ( + bsm_price as _rust_bsm_price, +) +from ferro_ta._ferro_ta import ( + bsm_price_batch as _rust_bsm_price_batch, +) +from ferro_ta._ferro_ta import ( + expected_move as _rust_expected_move, +) +from ferro_ta._ferro_ta import ( + extended_greeks as _rust_extended_greeks, +) +from ferro_ta._ferro_ta import ( + extended_greeks_batch as _rust_extended_greeks_batch, +) +from ferro_ta._ferro_ta import ( + implied_volatility as _rust_implied_volatility, +) +from ferro_ta._ferro_ta import ( + implied_volatility_batch as _rust_implied_volatility_batch, +) +from ferro_ta._ferro_ta import ( + iv_percentile as _rust_iv_percentile, +) +from ferro_ta._ferro_ta import ( + iv_rank as _rust_iv_rank, +) +from ferro_ta._ferro_ta import ( + iv_zscore as _rust_iv_zscore, +) +from ferro_ta._ferro_ta import ( + moneyness_labels as _rust_moneyness_labels, +) +from ferro_ta._ferro_ta import ( + option_greeks as _rust_option_greeks, +) +from ferro_ta._ferro_ta import ( + option_greeks_batch as _rust_option_greeks_batch, +) +from ferro_ta._ferro_ta import ( + put_call_parity_deviation as _rust_put_call_parity_deviation, +) +from ferro_ta._ferro_ta import ( + select_strike_delta as _rust_select_strike_delta, +) +from ferro_ta._ferro_ta import ( + select_strike_offset as _rust_select_strike_offset, +) +from ferro_ta._ferro_ta import ( + smile_metrics as _rust_smile_metrics, +) +from ferro_ta._ferro_ta import ( + term_structure_slope as _rust_term_structure_slope, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import ( + FerroTAInputError, + FerroTAValueError, + _normalize_rust_error, +) + +ScalarOrArray: TypeAlias = float | NDArray[np.float64] + +__all__ = [ + "OptionGreeks", + "ExtendedGreeks", + "SmileMetrics", + "VolCone", + "black_scholes_price", + "black_76_price", + "option_price", + "greeks", + "extended_greeks", + "implied_volatility", + "smile_metrics", + "term_structure_slope", + "label_moneyness", + "select_strike", + "iv_rank", + "iv_percentile", + "iv_zscore", + "put_call_parity_deviation", + "expected_move", + "digital_option_price", + "digital_option_greeks", + "american_option_price", + "early_exercise_premium", + "close_to_close_vol", + "parkinson_vol", + "garman_klass_vol", + "rogers_satchell_vol", + "yang_zhang_vol", + "vol_cone", +] + + +@dataclass(frozen=True) +class ExtendedGreeks: + """Container for second-order and cross Greeks.""" + + vanna: ScalarOrArray + volga: ScalarOrArray + charm: ScalarOrArray + speed: ScalarOrArray + color: ScalarOrArray + + def to_dict(self) -> dict[str, ScalarOrArray]: + return { + "vanna": self.vanna, + "volga": self.volga, + "charm": self.charm, + "speed": self.speed, + "color": self.color, + } + + +@dataclass(frozen=True) +class VolCone: + """Historical realized vol distribution across window lengths.""" + + windows: NDArray[np.float64] + min: NDArray[np.float64] + p25: NDArray[np.float64] + median: NDArray[np.float64] + p75: NDArray[np.float64] + max: NDArray[np.float64] + + def to_dict(self) -> dict[str, NDArray[np.float64]]: + return { + "windows": self.windows, + "min": self.min, + "p25": self.p25, + "median": self.median, + "p75": self.p75, + "max": self.max, + } + + +@dataclass(frozen=True) +class OptionGreeks: + """Container for first-order Greeks.""" + + delta: ScalarOrArray + gamma: ScalarOrArray + vega: ScalarOrArray + theta: ScalarOrArray + rho: ScalarOrArray + + def to_dict(self) -> dict[str, ScalarOrArray]: + return { + "delta": self.delta, + "gamma": self.gamma, + "vega": self.vega, + "theta": self.theta, + "rho": self.rho, + } + + +@dataclass(frozen=True) +class SmileMetrics: + """Summary metrics for a single smile slice.""" + + atm_iv: float + risk_reversal_25d: float + butterfly_25d: float + skew_slope: float + convexity: float + + def to_dict(self) -> dict[str, float]: + return { + "atm_iv": self.atm_iv, + "risk_reversal_25d": self.risk_reversal_25d, + "butterfly_25d": self.butterfly_25d, + "skew_slope": self.skew_slope, + "convexity": self.convexity, + } + + +def _validate_option_type(option_type: str) -> str: + value = option_type.lower() + if value not in {"call", "put"}: + raise FerroTAValueError("option_type must be 'call' or 'put'.") + return value + + +def _validate_model(model: str) -> str: + value = model.lower() + aliases = { + "bsm": "bsm", + "black_scholes": "bsm", + "black-scholes": "bsm", + "blackscholes": "bsm", + "black76": "black76", + "black_76": "black76", + "black-76": "black76", + } + if value not in aliases: + raise FerroTAValueError( + "model must be one of 'bsm', 'black_scholes', or 'black76'." + ) + return aliases[value] + + +def _coerce_1d(data: ArrayLike | float, *, name: str) -> tuple[np.ndarray, bool]: + arr = np.asarray(data, dtype=np.float64) + if arr.ndim > 1: + raise FerroTAInputError(f"{name} must be a scalar or 1-D array.") + return np.ascontiguousarray(arr.reshape(-1)), arr.ndim == 0 + + +def _broadcast_inputs( + **kwargs: ArrayLike | float, +) -> tuple[dict[str, np.ndarray], bool]: + arrays: dict[str, np.ndarray] = {} + scalar_flags: list[bool] = [] + for name, value in kwargs.items(): + arr, is_scalar = _coerce_1d(value, name=name) + arrays[name] = arr + scalar_flags.append(is_scalar) + try: + broadcast = np.broadcast_arrays(*arrays.values()) + except ValueError as err: + raise FerroTAInputError( + f"Inputs could not be broadcast together: {', '.join(arrays.keys())}" + ) from err + out = { + name: np.ascontiguousarray(arr, dtype=np.float64).reshape(-1) + for name, arr in zip(arrays.keys(), broadcast) + } + return out, all(scalar_flags) + + +def _result_or_scalar(result: np.ndarray, scalar_mode: bool) -> ScalarOrArray: + return float(result[0]) if scalar_mode else result + + +def iv_rank(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]: + """Compute rolling IV rank in Rust while preserving the legacy API.""" + try: + arr = _to_f64(iv_series) + except ValueError as err: + raise FerroTAInputError(str(err)) from err + if len(arr) == 0: + raise FerroTAInputError("iv_series must not be empty.") + if window < 1: + raise FerroTAValueError(f"window must be >= 1, got {window}.") + try: + return np.asarray(_rust_iv_rank(arr, int(window)), dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def iv_percentile(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]: + """Compute rolling IV percentile in Rust while preserving the legacy API.""" + try: + arr = _to_f64(iv_series) + except ValueError as err: + raise FerroTAInputError(str(err)) from err + if len(arr) == 0: + raise FerroTAInputError("iv_series must not be empty.") + if window < 1: + raise FerroTAValueError(f"window must be >= 1, got {window}.") + try: + return np.asarray(_rust_iv_percentile(arr, int(window)), dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def iv_zscore(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]: + """Compute rolling IV z-score in Rust while preserving the legacy API.""" + try: + arr = _to_f64(iv_series) + except ValueError as err: + raise FerroTAInputError(str(err)) from err + if len(arr) == 0: + raise FerroTAInputError("iv_series must not be empty.") + if window < 1: + raise FerroTAValueError(f"window must be >= 1, got {window}.") + try: + return np.asarray(_rust_iv_zscore(arr, int(window)), dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def black_scholes_price( + spot: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + dividend_yield: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """Price options under Black-Scholes-Merton.""" + option_type = _validate_option_type(option_type) + arrays, scalar_mode = _broadcast_inputs( + spot=spot, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + dividend_yield=dividend_yield, + ) + try: + if scalar_mode: + return float( + _rust_bsm_price( + float(arrays["spot"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + float(arrays["dividend_yield"][0]), + ) + ) + out = _rust_bsm_price_batch( + arrays["spot"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + arrays["dividend_yield"], + option_type, + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def black_76_price( + forward: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", +) -> ScalarOrArray: + """Price options under Black-76.""" + option_type = _validate_option_type(option_type) + arrays, scalar_mode = _broadcast_inputs( + forward=forward, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + ) + try: + if scalar_mode: + return float( + _rust_black76_price( + float(arrays["forward"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + ) + ) + out = _rust_black76_price_batch( + arrays["forward"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def option_price( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + model: str = "bsm", + carry: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """Model-dispatched option price helper.""" + model = _validate_model(model) + if model == "black76": + return black_76_price( + underlying, + strike, + rate, + time_to_expiry, + volatility, + option_type=option_type, + ) + return black_scholes_price( + underlying, + strike, + rate, + time_to_expiry, + volatility, + option_type=option_type, + dividend_yield=carry, + ) + + +def greeks( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + model: str = "bsm", + carry: ArrayLike | float = 0.0, +) -> OptionGreeks: + """Return delta, gamma, vega, theta, and rho.""" + option_type = _validate_option_type(option_type) + model = _validate_model(model) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + delta, gamma, vega, theta, rho = _rust_option_greeks( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + model, + float(arrays["carry"][0]), + ) + return OptionGreeks(delta, gamma, vega, theta, rho) + + delta, gamma, vega, theta, rho = _rust_option_greeks_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + model, + arrays["carry"], + ) + return OptionGreeks( + np.asarray(delta, dtype=np.float64), + np.asarray(gamma, dtype=np.float64), + np.asarray(vega, dtype=np.float64), + np.asarray(theta, dtype=np.float64), + np.asarray(rho, dtype=np.float64), + ) + except ValueError as err: + _normalize_rust_error(err) + + +def implied_volatility( + price: ArrayLike | float, + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + *, + option_type: str = "call", + model: str = "bsm", + carry: ArrayLike | float = 0.0, + initial_guess: ArrayLike | float = 0.2, + tolerance: float = 1e-8, + max_iterations: int = 100, +) -> ScalarOrArray: + """Invert option prices to implied volatility.""" + option_type = _validate_option_type(option_type) + model = _validate_model(model) + arrays, scalar_mode = _broadcast_inputs( + price=price, + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + carry=carry, + initial_guess=initial_guess, + ) + try: + if scalar_mode: + return float( + _rust_implied_volatility( + float(arrays["price"][0]), + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + option_type, + model, + float(arrays["carry"][0]), + float(arrays["initial_guess"][0]), + float(tolerance), + int(max_iterations), + ) + ) + out = _rust_implied_volatility_batch( + arrays["price"], + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + option_type, + model, + arrays["carry"], + arrays["initial_guess"], + float(tolerance), + int(max_iterations), + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def smile_metrics( + strikes: ArrayLike, + vols: ArrayLike, + reference_price: float, + time_to_expiry: float, + *, + model: str = "bsm", + rate: float = 0.0, + carry: float = 0.0, +) -> SmileMetrics: + """Compute ATM IV, 25-delta RR/BF, skew slope, and convexity.""" + model = _validate_model(model) + strikes_arr = _to_f64(strikes) + vols_arr = _to_f64(vols) + order = np.argsort(strikes_arr) + strikes_arr = strikes_arr[order] + vols_arr = vols_arr[order] + try: + atm_iv, rr25, bf25, slope, convexity = _rust_smile_metrics( + strikes_arr, + vols_arr, + float(reference_price), + float(time_to_expiry), + model, + float(rate), + float(carry), + ) + except ValueError as err: + _normalize_rust_error(err) + return SmileMetrics(atm_iv, rr25, bf25, slope, convexity) + + +def term_structure_slope(tenors: ArrayLike, atm_ivs: ArrayLike) -> float: + """Slope of ATM IV against tenor.""" + try: + return float(_rust_term_structure_slope(_to_f64(tenors), _to_f64(atm_ivs))) + except ValueError as err: + _normalize_rust_error(err) + + +def label_moneyness( + strikes: ArrayLike, + reference_price: float, + *, + option_type: str = "call", +) -> NDArray[np.object_]: + """Label strikes as ``ITM``, ``ATM``, or ``OTM``.""" + option_type = _validate_option_type(option_type) + try: + codes = np.asarray( + _rust_moneyness_labels( + _to_f64(strikes), float(reference_price), option_type + ), + dtype=np.int8, + ) + except ValueError as err: + _normalize_rust_error(err) + mapping = np.array(["OTM", "ATM", "ITM"], dtype=object) + return mapping[codes + 1] + + +def _parse_selector_steps(selector: str) -> int: + suffix = selector[3:] + if suffix == "": + return 1 + try: + return int(suffix) + except ValueError as err: + raise FerroTAValueError( + f"Could not parse strike selector '{selector}'. Expected forms like ATM, ITM1, OTM2." + ) from err + + +def select_strike( + strikes: ArrayLike, + reference_price: float, + *, + option_type: str = "call", + selector: str = "ATM", + delta_target: float | None = None, + volatilities: ArrayLike | None = None, + time_to_expiry: float | None = None, + model: str = "bsm", + rate: float = 0.0, + carry: float = 0.0, +) -> float | None: + """Select a strike by ATM/ITM/OTM offset or delta target.""" + option_type = _validate_option_type(option_type) + model = _validate_model(model) + strikes_arr = _to_f64(strikes) + + if len(strikes_arr) == 0: + raise FerroTAInputError("strikes must not be empty.") + + selector_norm = selector.strip().upper() + if delta_target is None and selector_norm.startswith("DELTA"): + try: + delta_target = float(selector_norm.replace("DELTA", "")) + except ValueError as err: + raise FerroTAValueError( + f"Could not parse delta selector '{selector}'. Example: selector='DELTA0.25'." + ) from err + + if delta_target is not None: + if volatilities is None or time_to_expiry is None: + raise FerroTAValueError( + "Delta-based strike selection requires volatilities and time_to_expiry." + ) + vols_arr = _to_f64(volatilities) + if len(vols_arr) != len(strikes_arr): + raise FerroTAInputError( + "strikes and volatilities must have the same length." + ) + order = np.argsort(strikes_arr) + strikes_arr = strikes_arr[order] + vols_arr = vols_arr[order] + try: + strike = _rust_select_strike_delta( + strikes_arr, + vols_arr, + float(reference_price), + float(time_to_expiry), + float(delta_target), + option_type, + model, + float(rate), + float(carry), + ) + except ValueError as err: + _normalize_rust_error(err) + return None if strike is None else float(strike) + + order = np.argsort(strikes_arr) + sorted_strikes = strikes_arr[order] + if selector_norm == "ATM": + offset = 0 + elif selector_norm.startswith("ITM"): + steps = _parse_selector_steps(selector_norm) + offset = -steps if option_type == "call" else steps + elif selector_norm.startswith("OTM"): + steps = _parse_selector_steps(selector_norm) + offset = steps if option_type == "call" else -steps + else: + raise FerroTAValueError( + f"Unsupported selector '{selector}'. Use ATM, ITM, OTM, or DELTA." + ) + + try: + strike = _rust_select_strike_offset( + sorted_strikes, float(reference_price), int(offset) + ) + except ValueError as err: + _normalize_rust_error(err) + return None if strike is None else float(strike) + + +def extended_greeks( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + model: str = "bsm", + carry: ArrayLike | float = 0.0, +) -> ExtendedGreeks: + """Return vanna, volga, charm, speed, and color (second-order / cross Greeks). + + All Greeks are computed via closed-form BSM formulas. Black-76 is not + yet supported and returns NaN for all five values. + + Parameters + ---------- + underlying: + Current underlying (spot) price. + strike: + Option strike price. + rate: + Risk-free rate (annualised, decimal — e.g. ``0.05`` for 5 %). + time_to_expiry: + Time to expiry in years. + volatility: + Implied volatility (annualised, decimal). + option_type: + ``"call"`` (default) or ``"put"``. + model: + ``"bsm"`` (default). ``"black76"`` returns NaN for all fields. + carry: + Continuous carry / dividend yield (annualised, decimal). Default 0. + + Returns + ------- + ExtendedGreeks + Named tuple with fields: + + - **vanna** — ∂Δ/∂σ: sensitivity of delta to a change in vol. + - **volga** — ∂²V/∂σ² (vomma): sensitivity of vega to a change in vol. + - **charm** — ∂Δ/∂t: daily rate of change in delta (theta of delta). + - **speed** — ∂Γ/∂S: rate of change in gamma with respect to spot. + - **color** — ∂Γ/∂t: daily rate of change in gamma. + + Notes + ----- + Inputs may be scalars or broadcastable arrays. When arrays are supplied + each field of the returned :class:`ExtendedGreeks` is an ``NDArray``. + + Closed-form expressions (BSM, zero-carry):: + + vanna = -e^{-qT} · φ(d₁) · d₂ / σ + volga = S · e^{-qT} · φ(d₁) · √T · d₁ · d₂ / σ + charm = -e^{-qT} · φ(d₁) · [2(r-q)T - d₂·σ·√T] / (2T·σ·√T) + speed = -Γ/S · (d₁/(σ√T) + 1) + color = -Γ · [r-q + d₁·σ/(2√T) + (2(r-q)T - d₂·σ√T)·d₁/(2T·σ√T)] + """ + option_type = _validate_option_type(option_type) + model = _validate_model(model) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + vanna, volga, charm, speed, color = _rust_extended_greeks( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + model, + float(arrays["carry"][0]), + ) + return ExtendedGreeks(vanna, volga, charm, speed, color) + + vanna, volga, charm, speed, color = _rust_extended_greeks_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + model, + arrays["carry"], + ) + return ExtendedGreeks( + np.asarray(vanna, dtype=np.float64), + np.asarray(volga, dtype=np.float64), + np.asarray(charm, dtype=np.float64), + np.asarray(speed, dtype=np.float64), + np.asarray(color, dtype=np.float64), + ) + except ValueError as err: + _normalize_rust_error(err) + + +def put_call_parity_deviation( + call_price: float, + put_price: float, + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + carry: float = 0.0, +) -> float: + """Put-call parity deviation: ``C − P − (S·e^{−q·T} − K·e^{−r·T})``. + + At no-arbitrage the deviation is exactly 0. A non-zero result indicates + mispricing, a data error, or a stale quote. + + Parameters + ---------- + call_price: + Market or model price of the call option. + put_price: + Market or model price of the put option. + spot: + Current underlying price. + strike: + Common strike price of the call and put. + rate: + Risk-free rate (annualised, decimal). + time_to_expiry: + Time to expiry in years. + carry: + Continuous dividend yield / carry rate (annualised, decimal). + + Returns + ------- + float + Signed deviation. Positive → call is overpriced relative to put; + negative → put is overpriced relative to call. + + Examples + -------- + >>> from ferro_ta.analysis.options import option_price, put_call_parity_deviation + >>> call = option_price(100, 100, 0.05, 1.0, 0.2, option_type="call") + >>> put = option_price(100, 100, 0.05, 1.0, 0.2, option_type="put") + >>> put_call_parity_deviation(call, put, 100, 100, 0.05, 1.0) # ≈ 0.0 + """ + try: + return float( + _rust_put_call_parity_deviation( + float(call_price), + float(put_price), + float(spot), + float(strike), + float(rate), + float(time_to_expiry), + float(carry), + ) + ) + except ValueError as err: + _normalize_rust_error(err) + + +def expected_move( + spot: float, + iv: float, + days_to_expiry: float, + trading_days_per_year: float = 252.0, +) -> tuple[float, float]: + """Expected ±1σ move over *days_to_expiry* calendar days. + + Uses the log-normal approximation:: + + upper_move = spot × e^{+σ√(days/trading_days)} − spot + lower_move = spot × e^{−σ√(days/trading_days)} − spot + + Parameters + ---------- + spot: + Current underlying price. + iv: + Implied volatility (annualised, decimal — e.g. ``0.20`` for 20 %). + days_to_expiry: + Number of calendar days until expiry. + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + tuple[float, float] + ``(lower_move, upper_move)`` — signed absolute price changes from + ``spot``. ``lower_move < 0``, ``upper_move > 0``. + + Notes + ----- + Because of log-normal skew, ``|upper_move| > |lower_move|``. + + Examples + -------- + >>> from ferro_ta.analysis.options import expected_move + >>> lower, upper = expected_move(100.0, 0.20, 30) + >>> round(upper, 2) + 7.14 + """ + try: + lower, upper = _rust_expected_move( + float(spot), float(iv), float(days_to_expiry), float(trading_days_per_year) + ) + return float(lower), float(upper) + except ValueError as err: + _normalize_rust_error(err) + + +# --------------------------------------------------------------------------- +# Digital options — populated once the Rust bridge is built +# --------------------------------------------------------------------------- + + +def digital_option_price( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + digital_type: str = "cash_or_nothing", + carry: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """Price a digital (binary) option under BSM. + + Parameters + ---------- + underlying: + Current underlying (spot) price. + strike: + Option strike price. + rate: + Risk-free rate (annualised, decimal). + time_to_expiry: + Time to expiry in years. + volatility: + Implied volatility (annualised, decimal). + option_type: + ``"call"`` (default) or ``"put"``. + digital_type: + ``"cash_or_nothing"`` (default) — pays 1 unit of cash if ITM at + expiry; or ``"asset_or_nothing"`` — pays the underlying asset price. + carry: + Continuous carry / dividend yield (annualised, decimal). Default 0. + + Returns + ------- + float or NDArray[float64] + Option price. Returns a scalar when all inputs are scalars, or an + array when any input is an array. + + Notes + ----- + Closed-form BSM formulas:: + + Cash-or-nothing call: e^{−rT} · N(d₂) + Cash-or-nothing put: e^{−rT} · N(−d₂) + Asset-or-nothing call: S · e^{−qT} · N(d₁) + Asset-or-nothing put: S · e^{−qT} · N(−d₁) + + Put-call parity for cash-or-nothing: call + put = e^{−rT}. + Put-call parity for asset-or-nothing: call + put = S · e^{−qT}. + + Invalid inputs (non-positive spot/strike, negative time or vol) return NaN. + """ + from ferro_ta._ferro_ta import digital_price as _rust_digital_price + from ferro_ta._ferro_ta import digital_price_batch as _rust_digital_price_batch + + option_type = _validate_option_type(option_type) + digital_type = digital_type.lower().replace("-", "_") + if digital_type not in {"cash_or_nothing", "asset_or_nothing"}: + raise FerroTAValueError( + "digital_type must be 'cash_or_nothing' or 'asset_or_nothing'." + ) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + return float( + _rust_digital_price( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + digital_type, + float(arrays["carry"][0]), + ) + ) + out = _rust_digital_price_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + digital_type, + arrays["carry"], + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def digital_option_greeks( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + digital_type: str = "cash_or_nothing", + carry: ArrayLike | float = 0.0, +) -> OptionGreeks: + """Delta, gamma, and vega for a digital option via numerical bumping. + + Uses central finite differences (spot bump ε = spot × 10⁻³ for delta/gamma; + vol bump ε = 10⁻³ for vega). Theta and rho are set to NaN. + + Parameters + ---------- + underlying, strike, rate, time_to_expiry, volatility, option_type, carry: + Same as :func:`digital_option_price`. + digital_type: + ``"cash_or_nothing"`` (default) or ``"asset_or_nothing"``. + + Returns + ------- + OptionGreeks + Named tuple; only ``delta``, ``gamma``, ``vega`` are finite. + ``theta`` and ``rho`` are NaN. + """ + from ferro_ta._ferro_ta import digital_greeks as _rust_digital_greeks + from ferro_ta._ferro_ta import digital_greeks_batch as _rust_digital_greeks_batch + + option_type = _validate_option_type(option_type) + digital_type = digital_type.lower().replace("-", "_") + if digital_type not in {"cash_or_nothing", "asset_or_nothing"}: + raise FerroTAValueError( + "digital_type must be 'cash_or_nothing' or 'asset_or_nothing'." + ) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + delta, gamma, vega = _rust_digital_greeks( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + digital_type, + float(arrays["carry"][0]), + ) + return OptionGreeks(delta, gamma, vega, float("nan"), float("nan")) + + delta, gamma, vega = _rust_digital_greeks_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + digital_type, + arrays["carry"], + ) + nan_arr = np.full_like(delta, float("nan")) + return OptionGreeks( + np.asarray(delta, dtype=np.float64), + np.asarray(gamma, dtype=np.float64), + np.asarray(vega, dtype=np.float64), + nan_arr, + nan_arr, + ) + except ValueError as err: + _normalize_rust_error(err) + + +# --------------------------------------------------------------------------- +# American options — populated once the Rust bridge is built +# --------------------------------------------------------------------------- + + +def american_option_price( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + carry: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """American option price using the Barone-Adesi-Whaley (1987) approximation. + + Accurate to within a few basis points for standard equity/index parameters. + O(1) per evaluation — suitable for batch pricing or calibration. + + Parameters + ---------- + underlying: + Current underlying (spot) price. + strike: + Option strike price. + rate: + Risk-free rate (annualised, decimal). + time_to_expiry: + Time to expiry in years. + volatility: + Implied volatility (annualised, decimal). + option_type: + ``"call"`` (default) or ``"put"``. + carry: + Continuous carry / dividend yield (annualised, decimal). Default 0. + For calls with ``carry = 0`` (no dividends) early exercise is never + optimal and the result equals the European BSM price. + + Returns + ------- + float or NDArray[float64] + American option price ≥ European BSM price. + + Notes + ----- + The BAW approximation uses a quadratic equation to find the critical + exercise boundary S* via Newton-Raphson iteration, then adds the early + exercise premium on top of the European price. + + Reference: Barone-Adesi, G. & Whaley, R.E. (1987). "Efficient Analytic + Approximation of American Option Values." *Journal of Finance*, 42(2), + 301–320. + + See Also + -------- + early_exercise_premium : Difference between American and European prices. + """ + from ferro_ta._ferro_ta import american_price as _rust_american_price + from ferro_ta._ferro_ta import american_price_batch as _rust_american_price_batch + + option_type = _validate_option_type(option_type) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + return float( + _rust_american_price( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + float(arrays["carry"][0]), + ) + ) + out = _rust_american_price_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + arrays["carry"], + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def early_exercise_premium( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + carry: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """Early exercise premium: American price − European BSM price. + + Represents the additional value an American option holder gains from the + right to exercise before expiry. Always ≥ 0. + + Parameters + ---------- + underlying, strike, rate, time_to_expiry, volatility, option_type, carry: + Same as :func:`american_option_price`. + + Returns + ------- + float or NDArray[float64] + Premium ≥ 0. Typically 0 for calls with no dividends. + + Notes + ----- + For equity calls with zero carry (no dividends), early exercise is never + optimal so the premium is ≈ 0. For puts (or calls on dividend-paying + underlyings), the premium increases with in-the-moneyness, rate, and + time to expiry. + """ + from ferro_ta._ferro_ta import ( + early_exercise_premium as _rust_early_exercise_premium, + ) + from ferro_ta._ferro_ta import ( + early_exercise_premium_batch as _rust_early_exercise_premium_batch, + ) + + option_type = _validate_option_type(option_type) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + return float( + _rust_early_exercise_premium( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + float(arrays["carry"][0]), + ) + ) + out = _rust_early_exercise_premium_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + arrays["carry"], + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +# --------------------------------------------------------------------------- +# Historical volatility estimators — populated once the Rust bridge is built +# --------------------------------------------------------------------------- + + +def close_to_close_vol( + close: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling close-to-close realized volatility (annualised). + + Baseline estimator — uses only closing prices. Less efficient than OHLC + estimators but requires only daily close data. + + Parameters + ---------- + close: + Array of closing prices (length ≥ window + 1). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *close*. First ``window`` values are NaN. + + Notes + ----- + Formula:: + + σ = √( Σᵢ ln²(Cᵢ/Cᵢ₋₁) / window × trading_days_per_year ) + + No Bessel correction is applied (population variance, not sample variance). + """ + from ferro_ta._ferro_ta import close_to_close_vol as _rust_ctc + + try: + arr = _to_f64(close) + return np.asarray( + _rust_ctc(arr, int(window), float(trading_days_per_year)), dtype=np.float64 + ) + except ValueError as err: + _normalize_rust_error(err) + + +def parkinson_vol( + high: ArrayLike, + low: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling Parkinson high-low realized volatility estimator (annualised). + + ~5× more efficient than close-to-close for diffusion processes. + Does **not** account for drift or overnight gaps. + + Parameters + ---------- + high, low: + Arrays of daily high and low prices (same length, ≥ window). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *high*. First ``window - 1`` values are NaN. + + Notes + ----- + Formula per window:: + + σ² = (1 / (4·ln2·window)) · Σ ln²(Hᵢ/Lᵢ) × trading_days_per_year + + Reference: Parkinson, M. (1980). "The Extreme Value Method for + Estimating the Variance of the Rate of Return." *Journal of Business*, 53(1). + """ + from ferro_ta._ferro_ta import parkinson_vol as _rust_parkinson + + try: + return np.asarray( + _rust_parkinson( + _to_f64(high), _to_f64(low), int(window), float(trading_days_per_year) + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def garman_klass_vol( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling Garman-Klass OHLC realized volatility estimator (annualised). + + Extends Parkinson by incorporating the open-close return. ~7.4× more + efficient than close-to-close. Does **not** handle overnight gaps. + + Parameters + ---------- + open, high, low, close: + Arrays of daily OHLC prices (same length, ≥ window). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *close*. First ``window - 1`` values are NaN. + + Notes + ----- + Per-bar contribution:: + + GK = 0.5·ln²(H/L) − (2·ln2 − 1)·ln²(C/O) + + Reference: Garman, M.B. & Klass, M.J. (1980). "On the Estimation of + Security Price Volatilities from Historical Data." *Journal of Business*, 53(1). + """ + from ferro_ta._ferro_ta import garman_klass_vol as _rust_gk + + try: + return np.asarray( + _rust_gk( + _to_f64(open), + _to_f64(high), + _to_f64(low), + _to_f64(close), + int(window), + float(trading_days_per_year), + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def rogers_satchell_vol( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling Rogers-Satchell OHLC realized volatility estimator (annualised). + + Drift-invariant: unbiased for assets with non-zero expected return. + Does **not** handle overnight gaps. + + Parameters + ---------- + open, high, low, close: + Arrays of daily OHLC prices (same length, ≥ window). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *close*. First ``window - 1`` values are NaN. + + Notes + ----- + Per-bar contribution (u = ln(H/O), d = ln(L/O), c = ln(C/O)):: + + RS = u·(u − c) + d·(d − c) + + Reference: Rogers, L.C.G. & Satchell, S.E. (1991). "Estimating Variance + from High, Low and Closing Prices." *Annals of Applied Probability*, 1(4). + """ + from ferro_ta._ferro_ta import rogers_satchell_vol as _rust_rs + + try: + return np.asarray( + _rust_rs( + _to_f64(open), + _to_f64(high), + _to_f64(low), + _to_f64(close), + int(window), + float(trading_days_per_year), + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def yang_zhang_vol( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling Yang-Zhang OHLC realized volatility estimator (annualised). + + The most efficient standard estimator (~14× vs close-to-close). Handles + overnight gaps by combining overnight, intraday open-close, and + Rogers-Satchell variance components with an optimal weight *k*. + + Parameters + ---------- + open, high, low, close: + Arrays of daily OHLC prices (same length, ≥ window + 1). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *close*. First ``window`` values are NaN. + + Notes + ----- + Mixed estimator:: + + σ²_YZ = σ²_overnight + k·σ²_open_close + (1−k)·σ²_RS + + where k = 0.34 / (1.34 + (window+1)/(window-1)). + + Reference: Yang, D. & Zhang, Q. (2000). "Drift-Independent Volatility + Estimation Based on High, Low, Open, and Close Prices." + *Journal of Business*, 73(3). + """ + from ferro_ta._ferro_ta import yang_zhang_vol as _rust_yz + + try: + return np.asarray( + _rust_yz( + _to_f64(open), + _to_f64(high), + _to_f64(low), + _to_f64(close), + int(window), + float(trading_days_per_year), + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def vol_cone( + close: ArrayLike, + *, + windows: tuple[int, ...] = (21, 42, 63, 126, 252), + trading_days_per_year: float = 252.0, +) -> VolCone: + """Historical realised vol distribution across window lengths (volatility cone). + + For each window, computes the full history of rolling close-to-close + realised vol, then returns the min / p25 / median / p75 / max distribution. + Contextualises current implied vol: "Is 30 % IV cheap or expensive?" + + Parameters + ---------- + close: + Array of closing prices (length ≥ max(windows) + 1). + windows: + Tuple of rolling window sizes in bars. Default ``(21, 42, 63, 126, 252)`` + (approx. 1 month, 2 months, 3 months, 6 months, 1 year). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + VolCone + Dataclass with arrays ``windows``, ``min``, ``p25``, ``median``, + ``p75``, ``max`` — one value per element of *windows*. + + Notes + ----- + Uses close-to-close vol internally. Overlay the current IV on the cone + to see whether it is historically cheap or expensive for each tenor. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.options import vol_cone + >>> rng = np.random.default_rng(0) + >>> close = 100 * np.cumprod(np.exp(rng.normal(0, 0.01, 500))) + >>> cone = vol_cone(close, windows=(21, 63, 252)) + >>> cone.median # annualised median realised vol per window + """ + from ferro_ta._ferro_ta import vol_cone as _rust_vol_cone + + try: + arr = _to_f64(close) + slices = _rust_vol_cone(arr, list(windows), float(trading_days_per_year)) + windows_arr = np.array([s[0] for s in slices], dtype=np.float64) + return VolCone( + windows=windows_arr, + min=np.array([s[1] for s in slices], dtype=np.float64), + p25=np.array([s[2] for s in slices], dtype=np.float64), + median=np.array([s[3] for s in slices], dtype=np.float64), + p75=np.array([s[4] for s in slices], dtype=np.float64), + max=np.array([s[5] for s in slices], dtype=np.float64), + ) + except ValueError as err: + _normalize_rust_error(err) diff --git a/ferro-ta-main/python/ferro_ta/analysis/options_strategy.py b/ferro-ta-main/python/ferro_ta/analysis/options_strategy.py new file mode 100644 index 0000000..ca0a7d5 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/options_strategy.py @@ -0,0 +1,326 @@ +""" +ferro_ta.analysis.options_strategy — Typed strategy parameter schemas. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import date +from enum import Enum +from typing import Any + +from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError + +__all__ = [ + "ExpirySelectorKind", + "StrikeSelectorKind", + "LegPreset", + "RiskMode", + "ExpirySelector", + "StrikeSelector", + "RiskControl", + "SimulationLimits", + "StrategyLeg", + "DerivativesStrategy", + "build_strategy_preset", +] + + +class ExpirySelectorKind(str, Enum): + CURRENT_WEEK = "current_week" + NEXT_WEEK = "next_week" + CURRENT_MONTH = "current_month" + NEXT_MONTH = "next_month" + EXPLICIT_DATE = "explicit_date" + + +class StrikeSelectorKind(str, Enum): + ATM = "atm" + ITM = "itm" + OTM = "otm" + DELTA = "delta" + EXPLICIT = "explicit" + + +class LegPreset(str, Enum): + STRADDLE = "straddle" + STRANGLE = "strangle" + IRON_CONDOR = "iron_condor" + BULL_CALL_SPREAD = "bull_call_spread" + BEAR_PUT_SPREAD = "bear_put_spread" + CUSTOM = "custom" + + +class RiskMode(str, Enum): + PER_LEG = "per_leg" + COMBINED_PNL = "combined_pnl" + + +@dataclass(frozen=True) +class ExpirySelector: + kind: ExpirySelectorKind | str + explicit_date: date | None = None + + def __post_init__(self) -> None: + kind = ExpirySelectorKind(self.kind) + object.__setattr__(self, "kind", kind) + if kind is ExpirySelectorKind.EXPLICIT_DATE and self.explicit_date is None: + raise FerroTAValueError( + "ExpirySelector(kind='explicit_date') requires explicit_date." + ) + if ( + kind is not ExpirySelectorKind.EXPLICIT_DATE + and self.explicit_date is not None + ): + raise FerroTAValueError( + "explicit_date is only valid when kind='explicit_date'." + ) + + +@dataclass(frozen=True) +class StrikeSelector: + kind: StrikeSelectorKind | str + steps: int = 0 + delta: float | None = None + explicit_strike: float | None = None + + def __post_init__(self) -> None: + kind = StrikeSelectorKind(self.kind) + object.__setattr__(self, "kind", kind) + if self.steps < 0: + raise FerroTAValueError("steps must be >= 0.") + if kind is StrikeSelectorKind.DELTA and self.delta is None: + raise FerroTAValueError( + "StrikeSelector(kind='delta') requires a delta target." + ) + if self.delta is not None and not (0.0 < float(self.delta) < 1.0): + raise FerroTAValueError("delta must be in the open interval (0, 1).") + if kind is StrikeSelectorKind.EXPLICIT and self.explicit_strike is None: + raise FerroTAValueError( + "StrikeSelector(kind='explicit') requires explicit_strike." + ) + + +@dataclass(frozen=True) +class RiskControl: + stop_loss_type: str | None = None + stop_loss_value: float | None = None + target_type: str | None = None + target_value: float | None = None + trailstop_type: str | None = None + trailstop_value: float | None = None + breakeven_trigger: float | None = None + + def __post_init__(self) -> None: + for name in ( + "stop_loss_value", + "target_value", + "trailstop_value", + "breakeven_trigger", + ): + value = getattr(self, name) + if value is not None and float(value) < 0.0: + raise FerroTAValueError(f"{name} must be >= 0.") + + +@dataclass(frozen=True) +class SimulationLimits: + max_premium_outlay: float | None = None + max_loss_per_trade: float | None = None + daily_max_drawdown: float | None = None + cooldown_bars: int = 0 + reentry_allowed: bool = True + + def __post_init__(self) -> None: + for name in ( + "max_premium_outlay", + "max_loss_per_trade", + "daily_max_drawdown", + ): + value = getattr(self, name) + if value is not None and float(value) < 0.0: + raise FerroTAValueError(f"{name} must be >= 0.") + if self.cooldown_bars < 0: + raise FerroTAValueError("cooldown_bars must be >= 0.") + + +@dataclass(frozen=True) +class StrategyLeg: + underlying: str + expiry_selector: ExpirySelector | None + strike_selector: StrikeSelector | None + option_type: str | None + side: str = "long" + quantity: int = 1 + instrument: str = "option" + premium_limit: float | None = None + + def __post_init__(self) -> None: + if self.underlying.strip() == "": + raise FerroTAInputError("underlying must not be empty.") + if self.instrument not in {"option", "future", "stock"}: + raise FerroTAValueError( + "instrument must be 'option', 'future', or 'stock'." + ) + if self.instrument == "option": + if self.option_type not in {"call", "put"}: + raise FerroTAValueError( + "option legs require option_type='call' or 'put'." + ) + if self.expiry_selector is None: + raise FerroTAInputError("option legs require expiry_selector.") + if self.strike_selector is None: + raise FerroTAInputError("option legs require strike_selector.") + if self.side not in {"long", "short"}: + raise FerroTAValueError("side must be 'long' or 'short'.") + if self.quantity == 0: + raise FerroTAValueError("quantity must be non-zero.") + if self.premium_limit is not None and self.premium_limit < 0.0: + raise FerroTAValueError("premium_limit must be >= 0.") + + +@dataclass(frozen=True) +class DerivativesStrategy: + name: str + preset: LegPreset | str = LegPreset.CUSTOM + legs: tuple[StrategyLeg, ...] = field(default_factory=tuple) + risk_controls: RiskControl = field(default_factory=RiskControl) + risk_mode: RiskMode | str = RiskMode.COMBINED_PNL + commission: float = 0.0 + slippage: float = 0.0 + spread_assumption: float = 0.0 + limits: SimulationLimits = field(default_factory=SimulationLimits) + + def __post_init__(self) -> None: + preset = LegPreset(self.preset) + risk_mode = RiskMode(self.risk_mode) + object.__setattr__(self, "preset", preset) + object.__setattr__(self, "risk_mode", risk_mode) + if self.name.strip() == "": + raise FerroTAInputError("name must not be empty.") + if len(self.legs) == 0: + raise FerroTAInputError("legs must contain at least one strategy leg.") + for cost_name in ("commission", "slippage", "spread_assumption"): + if float(getattr(self, cost_name)) < 0.0: + raise FerroTAValueError(f"{cost_name} must be >= 0.") + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def build_strategy_preset( + preset: LegPreset | str, + *, + name: str, + underlying: str, + expiry_selector: ExpirySelector, + base_strike_selector: StrikeSelector | None = None, + risk_controls: RiskControl | None = None, + risk_mode: RiskMode | str = RiskMode.COMBINED_PNL, + commission: float = 0.0, + slippage: float = 0.0, + spread_assumption: float = 0.0, + limits: SimulationLimits | None = None, +) -> DerivativesStrategy: + """Build a common research preset using typed leg definitions.""" + preset = LegPreset(preset) + risk_controls = risk_controls or RiskControl() + limits = limits or SimulationLimits() + atm = base_strike_selector or StrikeSelector(StrikeSelectorKind.ATM) + + if preset is LegPreset.CUSTOM: + raise FerroTAValueError( + "build_strategy_preset does not construct CUSTOM presets." + ) + + legs: tuple[StrategyLeg, ...] + + if preset is LegPreset.STRADDLE: + legs = ( + StrategyLeg(underlying, expiry_selector, atm, "call", "long"), + StrategyLeg(underlying, expiry_selector, atm, "put", "long"), + ) + elif preset is LegPreset.STRANGLE: + legs = ( + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "call", + "long", + ), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "put", + "long", + ), + ) + elif preset is LegPreset.BULL_CALL_SPREAD: + legs = ( + StrategyLeg(underlying, expiry_selector, atm, "call", "long"), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "call", + "short", + ), + ) + elif preset is LegPreset.BEAR_PUT_SPREAD: + legs = ( + StrategyLeg(underlying, expiry_selector, atm, "put", "long"), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "put", + "short", + ), + ) + elif preset is LegPreset.IRON_CONDOR: + legs = ( + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "put", + "short", + ), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=2), + "put", + "long", + ), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "call", + "short", + ), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=2), + "call", + "long", + ), + ) + else: + raise FerroTAValueError(f"Unsupported preset '{preset.value}'.") + + return DerivativesStrategy( + name=name, + preset=preset, + legs=legs, + risk_controls=risk_controls, + risk_mode=risk_mode, + commission=commission, + slippage=slippage, + spread_assumption=spread_assumption, + limits=limits, + ) diff --git a/ferro-ta-main/python/ferro_ta/analysis/plot.py b/ferro-ta-main/python/ferro_ta/analysis/plot.py new file mode 100644 index 0000000..43c2d36 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/python/ferro_ta/analysis/portfolio.py b/ferro-ta-main/python/ferro_ta/analysis/portfolio.py new file mode 100644 index 0000000..5291014 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/portfolio.py @@ -0,0 +1,240 @@ +""" +ferro_ta.portfolio — Portfolio and multi-asset analytics. + +Compute-intensive portfolio metrics (correlation, volatility, beta, drawdown) +are implemented in Rust; this module provides the Python-facing API. + +Functions +--------- +correlation_matrix(returns_df_or_array) + Compute the pairwise Pearson correlation matrix for a returns table. + +portfolio_volatility(returns, weights) + Compute portfolio volatility sqrt(w' Σ w) from a returns table and + weights (or pass a covariance matrix directly). + +beta(asset_returns, benchmark_returns, *, window=None) + Compute beta of one asset vs a benchmark, full-sample or rolling. + +drawdown(equity, *, as_series=True) + Compute the drawdown series and max drawdown for an equity curve. + +Rust backend +------------ +All compute delegates to:: + + ferro_ta._ferro_ta.correlation_matrix + ferro_ta._ferro_ta.portfolio_volatility + ferro_ta._ferro_ta.beta_full + ferro_ta._ferro_ta.rolling_beta + ferro_ta._ferro_ta.drawdown_series +""" + +from __future__ import annotations + +from typing import Any, Optional, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import beta_full as _rust_beta_full +from ferro_ta._ferro_ta import correlation_matrix as _rust_corr +from ferro_ta._ferro_ta import drawdown_series as _rust_drawdown +from ferro_ta._ferro_ta import portfolio_volatility as _rust_port_vol +from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta +from ferro_ta._utils import _to_f64 + +__all__ = [ + "correlation_matrix", + "portfolio_volatility", + "beta", + "drawdown", +] + + +# --------------------------------------------------------------------------- +# correlation_matrix +# --------------------------------------------------------------------------- + + +def correlation_matrix(returns: Any) -> Any: + """Compute the pairwise Pearson correlation matrix. + + Parameters + ---------- + returns : pandas.DataFrame or 2-D array-like, shape (n_bars, n_assets) + Returns per bar and asset. Assets are columns. + + Returns + ------- + numpy.ndarray of shape (n_assets, n_assets), or pandas.DataFrame + with same column/index names if a DataFrame was passed. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.portfolio import correlation_matrix + >>> rng = np.random.default_rng(0) + >>> r = rng.normal(0, 0.01, (100, 3)) + >>> corr = correlation_matrix(r) + >>> corr.shape + (3, 3) + >>> abs(corr[0, 0] - 1.0) < 1e-10 + True + """ + try: + import pandas as pd + + if isinstance(returns, pd.DataFrame): + cols = returns.columns.tolist() + arr = returns.values.astype(np.float64, copy=False) + arr = np.ascontiguousarray(arr) + result = _rust_corr(arr) + return pd.DataFrame(result, index=cols, columns=cols) # type: ignore[arg-type] + except ImportError: + pass + arr = np.ascontiguousarray(returns, dtype=np.float64) + return _rust_corr(arr) + + +# --------------------------------------------------------------------------- +# portfolio_volatility +# --------------------------------------------------------------------------- + + +def portfolio_volatility( + returns: Any, + weights: ArrayLike, + *, + annualise: Optional[float] = None, +) -> float: + """Compute portfolio volatility sqrt(w' Σ w). + + Parameters + ---------- + returns : pandas.DataFrame or 2-D array-like, shape (n_bars, n_assets) + Returns per bar/asset. The covariance matrix is computed from this. + weights : array-like of length n_assets + Portfolio weights (do not need to sum to 1). + annualise : float, optional + If given, the result is multiplied by ``sqrt(annualise)`` (e.g. + ``252`` for daily returns annualised to yearly). + + Returns + ------- + float + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.portfolio import portfolio_volatility + >>> rng = np.random.default_rng(1) + >>> r = rng.normal(0, 0.01, (252, 3)) + >>> vol = portfolio_volatility(r, weights=[1/3, 1/3, 1/3]) + >>> vol > 0 + True + """ + try: + import pandas as pd + + if isinstance(returns, pd.DataFrame): + arr = returns.values.astype(np.float64, copy=False) + else: + arr = np.asarray(returns, dtype=np.float64) + except ImportError: + arr = np.asarray(returns, dtype=np.float64) + + arr = np.ascontiguousarray(arr) + cov = np.cov(arr.T) + if cov.ndim == 0: + cov = np.array([[float(cov)]]) + cov = np.ascontiguousarray(cov) + w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64)) + vol = _rust_port_vol(cov, w) + if annualise is not None: + vol *= float(annualise) ** 0.5 + return vol + + +# --------------------------------------------------------------------------- +# beta +# --------------------------------------------------------------------------- + + +def beta( + asset_returns: ArrayLike, + benchmark_returns: ArrayLike, + *, + window: Optional[int] = None, +) -> Union[float, NDArray[np.float64]]: + """Compute beta of an asset vs a benchmark. + + Parameters + ---------- + asset_returns, benchmark_returns : array-like + Fractional returns per bar (equal length, >= 2 elements). + window : int, optional + If given, compute rolling beta over a sliding window of this size. + Returns a 1-D array with ``NaN`` for the first ``window-1`` bars. + If ``None`` (default), return the full-sample scalar beta. + + Returns + ------- + float or numpy.ndarray + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.portfolio import beta + >>> rng = np.random.default_rng(2) + >>> bench = rng.normal(0, 0.01, 100) + >>> asset = 1.2 * bench + rng.normal(0, 0.001, 100) + >>> abs(beta(asset, bench) - 1.2) < 0.05 + True + """ + a = _to_f64(asset_returns) + b = _to_f64(benchmark_returns) + if window is not None: + return _rust_rolling_beta(a, b, int(window)) + return _rust_beta_full(a, b) + + +# --------------------------------------------------------------------------- +# drawdown +# --------------------------------------------------------------------------- + + +def drawdown( + equity: ArrayLike, + *, + as_series: bool = True, +) -> Union[tuple[NDArray[np.float64], float], float]: + """Compute the drawdown series and maximum drawdown. + + Parameters + ---------- + equity : array-like + Equity or price series (e.g. portfolio equity curve). + as_series : bool + If ``True`` (default), return ``(drawdown_array, max_drawdown)``. + If ``False``, return only the scalar max_drawdown. + + Returns + ------- + (numpy.ndarray, float) when *as_series* is True; + float when *as_series* is False. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.portfolio import drawdown + >>> eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0]) + >>> dd, max_dd = drawdown(eq) + >>> round(max_dd, 4) + -0.1818 + """ + eq = _to_f64(equity) + dd_arr, max_dd = _rust_drawdown(eq) + if as_series: + return dd_arr, max_dd + return max_dd diff --git a/ferro-ta-main/python/ferro_ta/analysis/regime.py b/ferro-ta-main/python/ferro_ta/analysis/regime.py new file mode 100644 index 0000000..b18b74e --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/regime.py @@ -0,0 +1,594 @@ +""" +ferro_ta.regime — Regime detection and structural breaks. +========================================================= + +Detect market regimes (trending vs ranging) and structural breaks in price or +indicator series using existing ferro-ta indicators plus rule-based methods. + +Functions +--------- +regime(ohlcv, method='adx', **kwargs) + Label each bar as trending (1), ranging (0), or warm-up (-1). + Supported methods: ``'adx'``, ``'combined'``. + +structural_breaks(series, method='cusum', **kwargs) + Detect structural breaks. Returns a binary mask (1 = break). + Supported methods: ``'cusum'``, ``'variance'``. + +regime_adx(adx, threshold=25.0) + Low-level: label bars using an ADX array directly. + +regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=0.005) + Low-level: ADX + ATR-ratio labelling. + +detect_breaks_cusum(series, window=20, threshold=3.0, slack=0.5) + Low-level: CUSUM-based structural break detection. + +rolling_variance_break(series, short_window=10, long_window=50, threshold=2.0) + Low-level: rolling variance ratio break detection. + +Rust backend +------------ + ferro_ta._ferro_ta.regime_adx + ferro_ta._ferro_ta.regime_combined + ferro_ta._ferro_ta.detect_breaks_cusum + ferro_ta._ferro_ta.rolling_variance_break +""" + +from __future__ import annotations + +from typing import Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + detect_breaks_cusum as _rust_detect_breaks_cusum, +) +from ferro_ta._ferro_ta import ( + regime_adx as _rust_regime_adx, +) +from ferro_ta._ferro_ta import ( + regime_combined as _rust_regime_combined, +) +from ferro_ta._ferro_ta import ( + rolling_variance_break as _rust_rolling_variance_break, +) +from ferro_ta._utils import _to_f64 + +__all__ = [ + "regime", + "structural_breaks", + "regime_adx", + "regime_combined", + "detect_breaks_cusum", + "rolling_variance_break", +] + +# type alias for OHLCV tuple +OHLCVTuple = tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike] + + +# --------------------------------------------------------------------------- +# Low-level wrappers +# --------------------------------------------------------------------------- + + +def regime_adx( + adx: ArrayLike, + threshold: float = 25.0, +) -> NDArray[np.int8]: + """Label each bar as trend (1), range (0), or warm-up (-1) using ADX. + + Parameters + ---------- + adx : array-like — ADX values (NaN during warm-up) + threshold : float — ADX level above which a bar is "trending" (default 25) + + Returns + ------- + numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up (NaN) + """ + return np.asarray( + _rust_regime_adx(_to_f64(adx), float(threshold)), + dtype=np.int8, + ) + + +def regime_combined( + adx: ArrayLike, + atr: ArrayLike, + close: ArrayLike, + adx_threshold: float = 25.0, + atr_pct_threshold: float = 0.005, +) -> NDArray[np.int8]: + """Label bars using ADX + ATR-as-%-of-close rule. + + A bar is "trending" when both: + - ``adx[i] > adx_threshold`` + - ``atr[i] / close[i] > atr_pct_threshold`` + + Parameters + ---------- + adx : array-like — ADX values + atr : array-like — ATR values + close : array-like — close prices + adx_threshold : float — ADX threshold (default 25.0) + atr_pct_threshold : float — minimum ATR/close ratio (default 0.005) + + Returns + ------- + numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` NaN + """ + return np.asarray( + _rust_regime_combined( + _to_f64(adx), + _to_f64(atr), + _to_f64(close), + float(adx_threshold), + float(atr_pct_threshold), + ), + dtype=np.int8, + ) + + +def detect_breaks_cusum( + series: ArrayLike, + window: int = 20, + threshold: float = 3.0, + slack: float = 0.5, +) -> NDArray[np.int8]: + """Detect structural breaks using CUSUM (cumulative sum) approach. + + Parameters + ---------- + series : array-like — price or indicator series to monitor + window : int — lookback window for mean/std estimation (>= 2, default 20) + threshold : float — CUSUM threshold in units of std (default 3.0) + slack : float — allowance term (default 0.5) + + Returns + ------- + numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere + """ + return np.asarray( + _rust_detect_breaks_cusum( + _to_f64(series), + int(window), + float(threshold), + float(slack), + ), + dtype=np.int8, + ) + + +def rolling_variance_break( + series: ArrayLike, + short_window: int = 10, + long_window: int = 50, + threshold: float = 2.0, +) -> NDArray[np.int8]: + """Detect volatility regime breaks using a rolling variance ratio test. + + Parameters + ---------- + series : array-like — returns or price series + short_window : int — recent variance lookback (>= 2, default 10) + long_window : int — baseline variance lookback (> short_window, default 50) + threshold : float — ratio short_var/long_var above which a break fires + (default 2.0) + + Returns + ------- + numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere + """ + return np.asarray( + _rust_rolling_variance_break( + _to_f64(series), + int(short_window), + int(long_window), + float(threshold), + ), + dtype=np.int8, + ) + + +# --------------------------------------------------------------------------- +# High-level API +# --------------------------------------------------------------------------- + + +def regime( + ohlcv: Union[OHLCVTuple, object], # also accepts pandas.DataFrame + method: str = "adx", + adx_threshold: float = 25.0, + atr_pct_threshold: float = 0.005, + adx_timeperiod: int = 14, + atr_timeperiod: int = 14, +) -> NDArray[np.int8]: + """Label each bar as trending (1) or ranging (0) using existing indicators. + + Parameters + ---------- + ohlcv : tuple ``(open, high, low, close, volume)`` or pandas DataFrame + method : str + - ``'adx'`` (default) — uses ADX > *adx_threshold* + - ``'combined'`` — uses ADX + ATR/close ratio + adx_threshold : float — ADX level threshold (default 25.0) + atr_pct_threshold : float — minimum ATR/close ratio for ``'combined'`` + (default 0.005 = 0.5%) + adx_timeperiod : int — ADX period (default 14) + atr_timeperiod : int — ATR period for combined method (default 14) + + Returns + ------- + numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.regime import regime + >>> rng = np.random.default_rng(1) + >>> n = 200 + >>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100 + >>> open_ = close * rng.uniform(0.998, 1.002, n) + >>> high = np.maximum(close, open_) + rng.uniform(0, 0.5, n) + >>> low = np.minimum(close, open_) - rng.uniform(0, 0.5, n) + >>> vol = rng.uniform(1000, 5000, n) + >>> labels = regime((open_, high, low, close, vol)) + >>> # Count trending bars (excluding warm-up) + >>> valid = labels[labels >= 0] + >>> trend_pct = (valid == 1).sum() / len(valid) + """ + from ferro_ta import ADX, ATR # local import to avoid circular dependency + + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame): + cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr] + high_arr = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index] + low_arr = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index] + close_arr = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index] + else: + _, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr] + except ImportError: + _, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr] + + adx_vals = np.asarray( + ADX(high_arr, low_arr, close_arr, timeperiod=adx_timeperiod), dtype=np.float64 + ) + + if method == "adx": + return regime_adx(adx_vals, threshold=adx_threshold) + elif method == "combined": + atr_vals = np.asarray( + ATR(high_arr, low_arr, close_arr, timeperiod=atr_timeperiod), + dtype=np.float64, + ) + return regime_combined( + adx_vals, + atr_vals, + close_arr, + adx_threshold=adx_threshold, + atr_pct_threshold=atr_pct_threshold, + ) + else: + 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", + window: int = 20, + threshold: float = 3.0, + slack: float = 0.5, + short_window: int = 10, + long_window: int = 50, + variance_threshold: float = 2.0, +) -> NDArray[np.int8]: + """Detect structural breaks in a series. + + Parameters + ---------- + series : array-like — price or returns series to monitor + method : str + - ``'cusum'`` (default) — CUSUM-based break detection + - ``'variance'`` — rolling variance ratio break detection + window : int — CUSUM lookback window (default 20) + threshold: float — CUSUM threshold in std units (default 3.0) + slack : float — CUSUM slack term (default 0.5) + short_window : int — short variance window for ``'variance'`` (default 10) + long_window : int — long variance window for ``'variance'`` (default 50) + variance_threshold : float — variance ratio threshold (default 2.0) + + Returns + ------- + numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.regime import structural_breaks + >>> rng = np.random.default_rng(42) + >>> # Create a series with a structural break in the middle + >>> s1 = rng.normal(0, 1, 100) + >>> s2 = rng.normal(5, 3, 100) # different mean/variance + >>> series = np.concatenate([s1, s2]) + >>> breaks = structural_breaks(series, method='cusum') + >>> int(breaks[100:115].any()) # break near index 100 + 1 + """ + if method == "cusum": + return detect_breaks_cusum( + series, window=window, threshold=threshold, slack=slack + ) + elif method == "variance": + return rolling_variance_break( + series, + short_window=short_window, + long_window=long_window, + threshold=variance_threshold, + ) + else: + raise ValueError( + f"Unknown structural_breaks method '{method}'. Use 'cusum' or 'variance'." + ) diff --git a/ferro-ta-main/python/ferro_ta/analysis/resample.py b/ferro-ta-main/python/ferro_ta/analysis/resample.py new file mode 100644 index 0000000..172c56f --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/python/ferro_ta/analysis/signals.py b/ferro-ta-main/python/ferro_ta/analysis/signals.py new file mode 100644 index 0000000..5920e46 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/analysis/signals.py @@ -0,0 +1,222 @@ +""" +ferro_ta.signals — Signal composition and screening. + +Provides helpers to combine multiple indicator outputs into a composite score +and to screen/rank symbols by that score. + +Functions +--------- +compose(signals, weights=None, method='weighted') + Combine a DataFrame (or 2-D array) of signals into one composite score + per bar. Methods: ``'weighted'`` (weighted sum), ``'rank'`` (rank-based), + ``'mean'`` (equal-weight mean). + +screen(scores, top_n=None, bottom_n=None, above=None, below=None) + Filter/rank a dict or Series of per-symbol scores. + +rank_signals(x) + Compute the fractional rank of each element in *x* (wrapper around Rust). + +Rust backend +------------ + ferro_ta._ferro_ta.compose_weighted + ferro_ta._ferro_ta.rank_series + ferro_ta._ferro_ta.top_n_indices + ferro_ta._ferro_ta.bottom_n_indices +""" + +from __future__ import annotations + +from typing import Any, Optional, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import bottom_n_indices as _rust_bottom_n +from ferro_ta._ferro_ta import compose_rank as _rust_compose_rank +from ferro_ta._ferro_ta import compose_weighted as _rust_compose_weighted +from ferro_ta._ferro_ta import rank_series as _rust_rank_series +from ferro_ta._ferro_ta import top_n_indices as _rust_top_n +from ferro_ta._utils import _to_f64 + +__all__ = [ + "compose", + "screen", + "rank_signals", +] + + +# --------------------------------------------------------------------------- +# rank_signals +# --------------------------------------------------------------------------- + + +def rank_signals(x: ArrayLike) -> NDArray[np.float64]: + """Compute the fractional rank of each element (1-based, ascending). + + Ties receive the average of their rank positions. + + Parameters + ---------- + x : array-like — 1-D + + Returns + ------- + numpy.ndarray of ranks in [1, n] + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.signals import rank_signals + >>> rank_signals(np.array([3.0, 1.0, 2.0])) + array([3., 1., 2.]) + """ + return _rust_rank_series(_to_f64(x)) + + +# --------------------------------------------------------------------------- +# compose +# --------------------------------------------------------------------------- + + +def compose( + signals: Any, + weights: Optional[ArrayLike] = None, + method: str = "weighted", +) -> NDArray[np.float64]: + """Combine multiple signal columns into one composite score per bar. + + Parameters + ---------- + signals : pandas.DataFrame or 2-D array-like, shape (n_bars, n_signals) + Each column is one indicator/signal. + weights : array-like of length n_signals, optional + Weights for each signal column. Required for ``method='weighted'``. + If ``None`` and method is ``'weighted'``, equal weights are used. + method : str + Composition method: + - ``'weighted'`` (default) — weighted sum (Rust fast path) + - ``'mean'`` — equal-weight mean (equivalent to weighted with 1/n) + - ``'rank'`` — sum of per-signal ranks (rank-based scoring) + + Returns + ------- + numpy.ndarray of length n_bars + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.signals import compose + >>> rng = np.random.default_rng(0) + >>> sigs = rng.standard_normal((50, 3)) + >>> score = compose(sigs, weights=[0.5, 0.3, 0.2]) + >>> score.shape + (50,) + """ + try: + import pandas as pd + + if isinstance(signals, pd.DataFrame): + arr = signals.values.astype(np.float64, copy=False) + else: + arr = np.asarray(signals, dtype=np.float64) + except ImportError: + arr = np.asarray(signals, dtype=np.float64) + + if arr.ndim == 1: + arr = arr.reshape(-1, 1) + n_bars, n_sigs = arr.shape + arr = np.ascontiguousarray(arr) + + if method == "mean": + w = np.full(n_sigs, 1.0 / n_sigs) + return _rust_compose_weighted(arr, w) + elif method == "rank": + return _rust_compose_rank(arr) + else: + # weighted (default) + if weights is None: + w = np.full(n_sigs, 1.0 / n_sigs) + else: + w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64)) + return _rust_compose_weighted(arr, w) + + +# --------------------------------------------------------------------------- +# screen +# --------------------------------------------------------------------------- + + +def screen( + scores: Union[dict[str, float], Any], + top_n: Optional[int] = None, + bottom_n: Optional[int] = None, + above: Optional[float] = None, + below: Optional[float] = None, +) -> Any: + """Filter and rank symbols by composite score. + + Parameters + ---------- + scores : dict {symbol: score} or pandas.Series or array-like + Per-symbol scores. + top_n : int, optional + Return the top-N symbols by score. + bottom_n : int, optional + Return the bottom-N symbols by score. + above : float, optional + Return all symbols with score > *above*. + below : float, optional + Return all symbols with score < *below*. + + Returns + ------- + dict {symbol: score} sorted by score (descending for top_n, ascending for + bottom_n), or a pandas.DataFrame if pandas is available and input is a + Series/DataFrame. + + Examples + -------- + >>> from ferro_ta.analysis.signals import screen + >>> scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3} + >>> result = screen(scores, top_n=2) + >>> list(result.keys()) + ['MSFT', 'AAPL'] + """ + # Normalise to dict + try: + import pandas as pd + + if isinstance(scores, pd.Series): + symbols = scores.index.tolist() # type: ignore[union-attr] + values = scores.values.astype(np.float64) # type: ignore[union-attr] + elif isinstance(scores, dict): + symbols = list(scores.keys()) + values = np.array(list(scores.values()), dtype=np.float64) + else: + symbols = list(range(len(scores))) + values = np.array(list(scores), dtype=np.float64) + except ImportError: + if isinstance(scores, dict): + symbols = list(scores.keys()) + values = np.array(list(scores.values()), dtype=np.float64) + else: + symbols = list(range(len(scores))) + values = np.array(list(scores), dtype=np.float64) + + if top_n is not None: + idxs = _rust_top_n(values, int(top_n)) + # Sort by score descending + idxs = sorted(idxs, key=lambda i: -values[i]) + return {symbols[i]: float(values[i]) for i in idxs} + if bottom_n is not None: + idxs = _rust_bottom_n(values, int(bottom_n)) + idxs = sorted(idxs, key=lambda i: values[i]) + return {symbols[i]: float(values[i]) for i in idxs} + if above is not None: + return {s: float(v) for s, v in zip(symbols, values) if v > above} + if below is not None: + return {s: float(v) for s, v in zip(symbols, values) if v < below} + # Default: return all sorted descending + order = sorted(range(len(values)), key=lambda i: -values[i]) + return {symbols[i]: float(values[i]) for i in order} diff --git a/ferro-ta-main/python/ferro_ta/core/__init__.py b/ferro-ta-main/python/ferro_ta/core/__init__.py new file mode 100644 index 0000000..25fd4a2 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/core/__init__.py @@ -0,0 +1,16 @@ +""" +ferro_ta.core — Core utilities: exceptions, configuration, logging, registry, raw bindings. + +Sub-modules +----------- +* :mod:`ferro_ta.core.exceptions` — Custom exception hierarchy and error helpers +* :mod:`ferro_ta.core.config` — Global configuration and defaults +* :mod:`ferro_ta.core.logging_utils` — Debug-logging helpers +* :mod:`ferro_ta.core.registry` — Indicator function registry +* :mod:`ferro_ta.core.raw` — Raw Rust-binding wrappers (zero-overhead pass-through) + +Import directly from sub-modules to avoid circular dependencies, e.g.:: + + from ferro_ta.core.exceptions import FerroTAError + from ferro_ta.core.registry import register, run +""" diff --git a/ferro-ta-main/python/ferro_ta/core/config.py b/ferro-ta-main/python/ferro_ta/core/config.py new file mode 100644 index 0000000..1a0d412 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/core/config.py @@ -0,0 +1,257 @@ +""" +ferro_ta.config — Global configuration and indicator defaults. + +This module provides a simple configuration system that allows you to set +global default values for indicator parameters (e.g. default RSI period) +without having to pass them on every call. Defaults are overridden by +explicit keyword arguments to any indicator function. + +Usage +----- +>>> import ferro_ta.core.config as config +>>> config.set_default("timeperiod", 20) # global fallback for all indicators +>>> config.set_default("RSI.timeperiod", 14) # RSI-specific override + +>>> from ferro_ta import RSI +>>> import numpy as np +>>> close = np.arange(1.0, 25.0) +>>> RSI(close) # uses RSI.timeperiod=14 from config +>>> RSI(close, timeperiod=5) # explicit argument wins + +Context manager +--------------- +Use :class:`Config` as a context manager for temporary overrides: + +>>> with config.Config(timeperiod=5): +... result = RSI(close) # timeperiod=5 inside the block + +Resetting +--------- +>>> config.reset() # remove all custom defaults + +API +--- +set_default(key, value) — Set a global default. *key* can be a plain + parameter name (``"timeperiod"``) or an + indicator-qualified name (``"RSI.timeperiod"``). +get_default(key, fallback) — Get the current default for *key*. +reset(key=None) — Reset one or all defaults to their built-in values. +Config(**overrides) — Context manager: temporarily set defaults. +""" + +from __future__ import annotations + +import threading +from typing import Any, Optional + +# --------------------------------------------------------------------------- +# Thread-local storage — each thread can have independent config snapshots +# (rare in practice but safe for testing). +# --------------------------------------------------------------------------- +_local = threading.local() + + +def _store() -> dict[str, Any]: + """Return the thread-local defaults store, creating it if necessary.""" + if not hasattr(_local, "defaults"): + _local.defaults = {} + return _local.defaults + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def set_default(key: str, value: Any) -> None: + """Set a global default parameter value. + + Parameters + ---------- + key : str + Parameter name (e.g. ``"timeperiod"``) or indicator-qualified name + (e.g. ``"RSI.timeperiod"``). Indicator-qualified defaults take + precedence over plain defaults when both are set. + value : any + Default value to store. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 20) + >>> config.set_default("RSI.timeperiod", 14) + """ + _store()[key] = value + + +def get_default(key: str, fallback: Any = None) -> Any: + """Return the current default for *key*, or *fallback* if not set. + + Parameters + ---------- + key : str + Parameter name (e.g. ``"timeperiod"``). + fallback : any, optional + Value returned when no default is set. + + Returns + ------- + any + The stored default value, or *fallback*. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 20) + >>> config.get_default("timeperiod") + 20 + >>> config.get_default("nonexistent", -1) + -1 + """ + return _store().get(key, fallback) + + +def get_defaults_for(indicator_name: str) -> dict[str, Any]: + """Return all applicable defaults for the given indicator. + + Indicator-qualified keys (``"RSI.timeperiod"``) override plain keys + (``"timeperiod"``) in the returned dict. + + Parameters + ---------- + indicator_name : str + Name of the indicator (e.g. ``"RSI"``). + + Returns + ------- + dict + Merged defaults where indicator-specific values override global ones. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 20) + >>> config.set_default("RSI.timeperiod", 14) + >>> config.get_defaults_for("RSI") + {'timeperiod': 14} + >>> config.get_defaults_for("SMA") + {'timeperiod': 20} + """ + store = _store() + prefix = f"{indicator_name}." + + # Start with plain defaults + result: dict[str, Any] = {} + for k, v in store.items(): + if "." not in k: + result[k] = v + + # Override with indicator-qualified defaults + for k, v in store.items(): + if k.startswith(prefix): + result[k[len(prefix) :]] = v + + return result + + +def reset(key: Optional[str] = None) -> None: + """Reset defaults. + + Parameters + ---------- + key : str, optional + If given, remove only this key. If ``None``, remove all defaults. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 20) + >>> config.reset("timeperiod") + >>> config.get_default("timeperiod") is None + True + >>> config.reset() # clear everything + """ + store = _store() + if key is None: + store.clear() + else: + store.pop(key, None) + + +def list_defaults() -> dict[str, Any]: + """Return a copy of all currently set defaults. + + Returns + ------- + dict + Copy of the current defaults store. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 10) + >>> config.list_defaults() + {'timeperiod': 10} + """ + return dict(_store()) + + +# --------------------------------------------------------------------------- +# Context manager +# --------------------------------------------------------------------------- + + +class Config: + """Context manager for temporary configuration overrides. + + On entry, applies the specified overrides on top of the current defaults. + On exit, restores the previous state exactly. + + Parameters + ---------- + **overrides + Key-value pairs to set temporarily. + + Examples + -------- + >>> import numpy as np + >>> import ferro_ta.core.config as config + >>> from ferro_ta import RSI + >>> close = np.arange(1.0, 25.0) + >>> with config.Config(timeperiod=5): + ... config.get_default("timeperiod") + 5 + >>> config.get_default("timeperiod") is None # restored after exit + True + """ + + def __init__(self, **overrides: Any) -> None: + self._overrides = overrides + self._saved: dict[str, Any] = {} + + def __enter__(self) -> Config: + store = _store() + # Save current values for all keys we're about to change + self._saved = {k: store.get(k) for k in self._overrides} + # Apply overrides + for k, v in self._overrides.items(): + store[k] = v + return self + + def __exit__(self, *_: Any) -> None: + store = _store() + for k, saved_v in self._saved.items(): + if saved_v is None: + store.pop(k, None) + else: + store[k] = saved_v + + +__all__ = [ + "set_default", + "get_default", + "get_defaults_for", + "reset", + "list_defaults", + "Config", +] diff --git a/ferro-ta-main/python/ferro_ta/core/exceptions.py b/ferro-ta-main/python/ferro_ta/core/exceptions.py new file mode 100644 index 0000000..aa7d3f3 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/core/exceptions.py @@ -0,0 +1,337 @@ +""" +Custom exception hierarchy for ferro_ta. + +Exception classes +----------------- +FerroTAError — Base class for all ferro_ta exceptions. +FerroTAValueError — Raised for invalid parameter values (e.g. timeperiod < 1). +FerroTAInputError — Raised for invalid input arrays (e.g. mismatched lengths, wrong dtype, unexpected NaN/Inf when strict mode is used). + +All custom exceptions inherit from both the ferro_ta base and the corresponding +built-in exception (ValueError) so that existing ``except ValueError`` clauses +continue to work after upgrading. + +Error codes +----------- +Every exception carries a ``code`` attribute (e.g. ``"FTERR001"``) for +programmatic handling: + + FTERR001 — Invalid parameter value (FerroTAValueError) + FTERR002 — Invalid input array (FerroTAInputError) + FTERR003 — Input array too short (FerroTAInputError) + FTERR004 — Input arrays have mismatched lengths (FerroTAInputError) + FTERR005 — Input array contains NaN or Inf (FerroTAInputError, strict mode) + FTERR006 — General Rust-bridge error (FerroTAValueError or FerroTAInputError) + +Examples +-------- +>>> from ferro_ta.core.exceptions import FerroTAError, FerroTAValueError, FerroTAInputError +>>> raise FerroTAValueError("timeperiod must be >= 1, got 0") +Traceback (most recent call last): + ... +ferro_ta.exceptions.FerroTAValueError: [FTERR001] timeperiod must be >= 1, got 0 +>>> try: +... raise FerroTAValueError("bad value") +... except FerroTAValueError as exc: +... print(exc.code) +FTERR001 + +NaN / Inf policy +---------------- +By default ferro_ta **propagates** NaN and Inf in input arrays — output values +that depend on a NaN/Inf input will themselves be NaN/Inf. No exception is +raised for NaN or Inf values in the input data. If you need strict mode, call +:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them. +""" + +from __future__ import annotations + +from typing import NoReturn + +# --------------------------------------------------------------------------- +# Error code registry +# --------------------------------------------------------------------------- + +#: Maps each ``FerroTAError`` subclass to its default error code. +ERROR_CODES: dict[str, str] = { + "FerroTAError": "FTERR000", + "FerroTAValueError": "FTERR001", + "FerroTAInputError": "FTERR002", +} + +# Well-known codes for specific error kinds +_CODE_TOO_SHORT = "FTERR003" +_CODE_LENGTH_MISMATCH = "FTERR004" +_CODE_NOT_FINITE = "FTERR005" +_CODE_RUST_BRIDGE = "FTERR006" + +# Code descriptions (for reference and programmatic inspection) +ERROR_CODE_DESCRIPTIONS: dict[str, str] = { + "FTERR000": "General ferro_ta error (base class fallback)", + "FTERR001": "Invalid parameter value", + "FTERR002": "Invalid input array", + "FTERR003": "Input array too short", + "FTERR004": "Input arrays have mismatched lengths", + "FTERR005": "Input array contains NaN or Inf (strict mode)", + "FTERR006": "Rust-bridge error (re-raised from Rust ValueError)", +} + + +class FerroTAError(Exception): + """Base class for all ferro_ta exceptions. + + Attributes + ---------- + code : str + A short error code string (e.g. ``"FTERR001"``) for programmatic + handling. The code is included at the beginning of the exception + message. + suggestion : str | None + Optional human-readable suggestion for how to fix the error. + """ + + code: str = "FTERR000" + suggestion: str | None = None + + def __init__( + self, + message: str, + *, + code: str | None = None, + suggestion: str | None = None, + ) -> None: + self.code = code or type(self).code + self.suggestion = suggestion + full_msg = f"[{self.code}] {message}" + if suggestion: + full_msg = f"{full_msg}\n Suggestion: {suggestion}" + super().__init__(full_msg) + + +class FerroTAValueError(FerroTAError, ValueError): + """Raised when a parameter value is out of the accepted range. + + Examples: ``timeperiod < 1``, ``fastperiod >= slowperiod`` for MACD. + + Default error code: ``FTERR001``. + """ + + code = "FTERR001" + + +class FerroTAInputError(FerroTAError, ValueError): + """Raised when one or more input arrays are invalid. + + Examples: mismatched lengths for open/high/low/close, wrong dtype that + cannot be coerced to float64. + + Default error code: ``FTERR002``. + """ + + code = "FTERR002" + + +# --------------------------------------------------------------------------- +# Finer-grained exception subclasses (added in 1.2.0). +# +# These are drop-in compatible with the base classes: every subclass still +# inherits from ``FerroTAError`` and ``ValueError``, so existing user code +# like ``except FerroTAValueError:`` or ``except ValueError:`` keeps working. +# The subclasses exist so users can catch *specific* failure modes without +# string-matching on the error message. +# --------------------------------------------------------------------------- + + +class InvalidPeriodError(FerroTAValueError): + """Parameter like ``timeperiod``, ``fastperiod``, ``slowperiod`` is out of range. + + Default error code: ``FTERR001``. + """ + + +class InsufficientDataError(FerroTAInputError): + """Input array is shorter than the minimum required for the indicator. + + Default error code: ``FTERR003``. + """ + + code = "FTERR003" + + +class LengthMismatchError(FerroTAInputError): + """Two or more input arrays (e.g. OHLC) have different lengths. + + Default error code: ``FTERR004``. + """ + + code = "FTERR004" + + +class NumericConvergenceError(FerroTAValueError): + """An iterative calculation failed to converge within tolerance. + + Raised by iterative pricing models (implied volatility root-finding, + Newton-Raphson, etc.) when the maximum iteration count is exhausted. + """ + + +class InvalidInputError(FerroTAInputError): + """Input contains NaN/Inf in strict mode, wrong dtype, or wrong shape. + + Default error code: ``FTERR005``. + """ + + code = "FTERR005" + + +# Public aliases that match the names documented in the README and +# CHANGELOG [Unreleased] section. +FerroTaError = FerroTAError # type: ignore[misc] + +# --------------------------------------------------------------------------- +# Validation helpers (called by Python wrappers) +# --------------------------------------------------------------------------- + + +def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) -> None: + """Raise :class:`FerroTAValueError` if *value* < *minimum*. + + Parameters + ---------- + value: + The period parameter to validate. + name: + Human-readable parameter name for the error message. + minimum: + Minimum acceptable value (default 1). + + Raises + ------ + FerroTAValueError + If ``value < minimum``. + """ + if value < minimum: + raise InvalidPeriodError( + f"{name} must be >= {minimum}, got {value}", + suggestion=f"Set {name}={minimum} or higher.", + ) + + +def check_equal_length(**arrays: object) -> None: + """Raise :class:`FerroTAInputError` if the supplied arrays differ in length. + + Parameters + ---------- + **arrays: + Keyword arguments mapping name → array-like. At least two arrays + should be supplied for the check to be meaningful. + + Raises + ------ + FerroTAInputError + If any two arrays have different lengths. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.core.exceptions import check_equal_length + >>> check_equal_length(open=np.array([1.0]), close=np.array([1.0, 2.0])) + Traceback (most recent call last): + ... + ferro_ta.exceptions.FerroTAInputError: ... + """ + + lengths = {} + for name, arr in arrays.items(): + if hasattr(arr, "__len__"): + lengths[name] = len(arr) # type: ignore[arg-type] + elif hasattr(arr, "shape"): + lengths[name] = arr.shape[0] # type: ignore[union-attr] + + if len(set(lengths.values())) > 1: + detail = ", ".join(f"{k}={v}" for k, v in lengths.items()) + raise LengthMismatchError( + f"All input arrays must have the same length. Got: {detail}", + code=_CODE_LENGTH_MISMATCH, + suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.", + ) + + +def check_finite(arr: object, name: str = "input") -> None: + """Raise :class:`FerroTAInputError` if *arr* contains NaN or Inf. + + This is an *opt-in* strict-mode helper. ferro_ta does **not** call this + automatically — it is provided for users who want deterministic behaviour + when their data may contain missing values. + + Parameters + ---------- + arr: + Array-like to check. + name: + Human-readable name used in the error message. + + Raises + ------ + FerroTAInputError + If any element of *arr* is NaN or Inf. + """ + import numpy as np # local import + + a = np.asarray(arr, dtype=np.float64) + if not np.all(np.isfinite(a)): + raise InvalidInputError( + f"{name} contains NaN or Inf values. " + "ferro_ta propagates NaN by default; call check_finite() only " + "when you require all-finite inputs.", + code=_CODE_NOT_FINITE, + suggestion="Use numpy.nan_to_num() or dropna() to clean your data before passing it to ferro_ta.", + ) + + +def check_min_length(arr: object, min_len: int, name: str = "input") -> None: + """Raise :class:`FerroTAInputError` if *arr* has length less than *min_len*. + + Parameters + ---------- + arr: + Array-like to check. + min_len: + Minimum required length. + name: + Human-readable name used in the error message. + + Raises + ------ + FerroTAInputError + If ``len(arr) < min_len``. + """ + length = 0 + if hasattr(arr, "__len__"): + length = len(arr) # type: ignore[arg-type] + elif hasattr(arr, "shape"): + length = arr.shape[0] # type: ignore[union-attr] + if length < min_len: + raise InsufficientDataError( + f"{name} must have at least {min_len} elements, got {length}", + code=_CODE_TOO_SHORT, + suggestion=f"Provide at least {min_len} data points. Current length: {length}.", + ) + + +def _normalize_rust_error(err: ValueError) -> NoReturn: + """Re-raise a Rust-originated ValueError as FerroTAValueError or FerroTAInputError. + + Used by Python wrappers so users can catch FerroTA* exceptions consistently. + """ + msg = str(err).lower() + if ( + "length" in msg + or "same length" in msg + or "array" in msg + or "mismatch" in msg + or "dimension" in msg + or "1-d" in msg + ): + raise FerroTAInputError(str(err), code=_CODE_RUST_BRIDGE) from err + raise FerroTAValueError(str(err), code=_CODE_RUST_BRIDGE) from err diff --git a/ferro-ta-main/python/ferro_ta/core/logging_utils.py b/ferro-ta-main/python/ferro_ta/core/logging_utils.py new file mode 100644 index 0000000..41c7019 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/core/logging_utils.py @@ -0,0 +1,328 @@ +""" +ferro_ta.logging_utils — Logging integration and debug utilities. + +Provides a structured logging interface for ferro_ta with configurable +verbosity, debug mode, and optional performance timing. + +Usage +----- +>>> import ferro_ta.logging_utils as ft_log +>>> ft_log.enable_debug() # turn on DEBUG-level output +>>> ft_log.disable_debug() # back to WARNING level + +>>> # Use as a context manager for a single call: +>>> with ft_log.debug_mode(): +... result = ferro_ta.SMA(close, timeperiod=20) + +>>> # Access the ferro_ta logger directly: +>>> import logging +>>> logger = logging.getLogger("ferro_ta") +>>> logger.setLevel(logging.DEBUG) + +API +--- +get_logger() — Return the ``ferro_ta`` :class:`logging.Logger`. +enable_debug() — Set the ferro_ta logger to DEBUG level. +disable_debug() — Reset the ferro_ta logger to WARNING level. +debug_mode() — Context manager: temporarily enable debug logging. +log_call(func, ...) — Log a function call with input shapes and timing. +benchmark(func, ...) — Run *func* n times and return timing statistics. +""" + +from __future__ import annotations + +import contextlib +import functools +import logging +import time +from collections.abc import Callable, Iterator +from typing import Any, TypeVar + +__all__ = [ + "get_logger", + "enable_debug", + "disable_debug", + "debug_mode", + "log_call", + "benchmark", +] + +# --------------------------------------------------------------------------- +# Logger setup — single ``ferro_ta`` logger, handlers added lazily. +# --------------------------------------------------------------------------- + +_LOGGER_NAME = "ferro_ta" +_DEFAULT_FORMAT = "%(levelname)s [%(name)s] %(message)s" + +F = TypeVar("F", bound=Callable[..., Any]) + + +def get_logger() -> logging.Logger: + """Return the ``ferro_ta`` package logger. + + The logger is created on first call. A :class:`logging.NullHandler` is + installed so that no output appears by default (following the best-practice + for library loggers). Call :func:`enable_debug` or configure the logger + explicitly to see output. + + Returns + ------- + logging.Logger + The ``ferro_ta`` package logger. + """ + logger = logging.getLogger(_LOGGER_NAME) + if not logger.handlers: + logger.addHandler(logging.NullHandler()) + return logger + + +def enable_debug(fmt: str = _DEFAULT_FORMAT) -> None: + """Enable DEBUG-level logging for ferro_ta. + + Adds a :class:`logging.StreamHandler` that writes to *stderr* using *fmt* + and sets the logger level to ``DEBUG``. Calling this multiple times is + safe — duplicate handlers are not added. + + Parameters + ---------- + fmt: + Log message format string passed to :class:`logging.Formatter`. + """ + logger = get_logger() + logger.setLevel(logging.DEBUG) + # Avoid duplicate stream handlers + has_stream = any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + if not has_stream: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter(fmt)) + logger.addHandler(handler) + + +def disable_debug() -> None: + """Reset the ferro_ta logger to WARNING level and remove stream handlers.""" + logger = get_logger() + logger.setLevel(logging.WARNING) + logger.handlers = [h for h in logger.handlers if isinstance(h, logging.NullHandler)] + + +@contextlib.contextmanager +def debug_mode(fmt: str = _DEFAULT_FORMAT) -> Iterator[logging.Logger]: + """Context manager: enable debug logging for the duration of the block. + + Parameters + ---------- + fmt: + Log message format string. + + Yields + ------ + logging.Logger + The ``ferro_ta`` logger with DEBUG level active. + + Examples + -------- + >>> import numpy as np + >>> import ferro_ta.logging_utils as ft_log + >>> close = np.arange(1.0, 30.0) + >>> with ft_log.debug_mode(): + ... pass # ferro_ta calls inside here will log debug info + """ + prev_level = get_logger().level + enable_debug(fmt) + try: + yield get_logger() + finally: + disable_debug() + get_logger().setLevel(prev_level) + + +# --------------------------------------------------------------------------- +# Helper: shape summary for numpy / pandas / polars arrays +# --------------------------------------------------------------------------- + + +def _shape_str(obj: Any) -> str: + """Return a compact shape/type description for logging.""" + try: + import numpy as np # noqa: PLC0415 + + if isinstance(obj, np.ndarray): + return f"ndarray{obj.shape} dtype={obj.dtype}" + except ImportError: + pass + + if hasattr(obj, "shape"): + return f"{type(obj).__name__}{obj.shape}" + if hasattr(obj, "__len__"): + return f"{type(obj).__name__}[{len(obj)}]" # type: ignore[arg-type] + return repr(obj) + + +# --------------------------------------------------------------------------- +# log_call: decorator / manual call logger +# --------------------------------------------------------------------------- + + +def log_call( + func: Callable[..., Any], + *args: Any, + **kwargs: Any, +) -> Any: + """Call *func* with *args*/*kwargs*, logging input shapes and elapsed time. + + Parameters + ---------- + func: + The ferro_ta indicator function to call. + *args: + Positional arguments forwarded to *func*. + **kwargs: + Keyword arguments forwarded to *func*. + + Returns + ------- + Any + The return value of ``func(*args, **kwargs)``. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA + >>> import ferro_ta.logging_utils as ft_log + >>> ft_log.enable_debug() + >>> close = np.arange(1.0, 30.0) + >>> result = ft_log.log_call(SMA, close, timeperiod=5) + """ + logger = get_logger() + name = getattr(func, "__name__", repr(func)) + + if logger.isEnabledFor(logging.DEBUG): + arg_shapes = ", ".join(_shape_str(a) for a in args) + kwarg_shapes = ", ".join(f"{k}={_shape_str(v)}" for k, v in kwargs.items()) + all_args = ", ".join(filter(None, [arg_shapes, kwarg_shapes])) + logger.debug("calling %s(%s)", name, all_args) + + t0 = time.perf_counter() + result = func(*args, **kwargs) + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + + if logger.isEnabledFor(logging.DEBUG): + out_shape = ( + _shape_str(result) + if not isinstance(result, tuple) + else str(tuple(_shape_str(r) for r in result)) + ) + logger.debug("%s → %s [%.3f ms]", name, out_shape, elapsed_ms) + + return result + + +# --------------------------------------------------------------------------- +# benchmark: run a function N times and report timing statistics +# --------------------------------------------------------------------------- + + +def benchmark( + func: Callable[..., Any], + *args: Any, + n: int = 100, + warmup: int = 5, + **kwargs: Any, +) -> dict[str, float]: + """Benchmark *func* by calling it *n* times and returning timing stats. + + Parameters + ---------- + func: + The ferro_ta indicator function to benchmark. + *args: + Positional arguments forwarded to *func* on each call. + n: + Number of timed iterations (default 100). + warmup: + Number of warm-up calls before timing starts (default 5). + **kwargs: + Keyword arguments forwarded to *func* on each call. + + Returns + ------- + dict[str, float] + Dictionary with keys ``"mean_ms"``, ``"min_ms"``, ``"max_ms"``, + ``"total_ms"``, ``"n"``. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA + >>> import ferro_ta.logging_utils as ft_log + >>> close = np.random.randn(10_000) + >>> stats = ft_log.benchmark(SMA, close, timeperiod=20, n=50) + >>> print(f"mean={stats['mean_ms']:.3f} ms") + mean=... ms + """ + name = getattr(func, "__name__", repr(func)) + + for _ in range(warmup): + func(*args, **kwargs) + + times: list[float] = [] + for _ in range(n): + t0 = time.perf_counter() + func(*args, **kwargs) + times.append((time.perf_counter() - t0) * 1000.0) + + total = sum(times) + mean = total / n + stats: dict[str, float] = { + "mean_ms": mean, + "min_ms": min(times), + "max_ms": max(times), + "total_ms": total, + "n": float(n), + } + + logger = get_logger() + if logger.isEnabledFor(logging.INFO): + logger.info( + "benchmark %s n=%d mean=%.3f ms min=%.3f ms max=%.3f ms", + name, + n, + stats["mean_ms"], + stats["min_ms"], + stats["max_ms"], + ) + + return stats + + +# --------------------------------------------------------------------------- +# traced: decorator that wraps a function with log_call behaviour +# --------------------------------------------------------------------------- + + +def traced(func: F) -> F: + """Decorator: wrap *func* so every call is logged at DEBUG level. + + Parameters + ---------- + func: + Function to wrap. + + Returns + ------- + Callable + Wrapped function with identical signature. + + Examples + -------- + >>> import ferro_ta.logging_utils as ft_log + >>> @ft_log.traced + ... def my_indicator(close, timeperiod=14): + ... return close # placeholder + """ + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + return log_call(func, *args, **kwargs) + + return wrapper # type: ignore[return-value] diff --git a/ferro-ta-main/python/ferro_ta/core/raw.py b/ferro-ta-main/python/ferro_ta/core/raw.py new file mode 100644 index 0000000..5048268 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/core/raw.py @@ -0,0 +1,391 @@ +""" +ferro_ta.raw — Zero-overhead access to the compiled Rust extension. + +Importing from this module gives you direct access to the PyO3-compiled +indicator functions **without** the pandas/polars wrapping, Python validation, +or ``_to_f64`` conversion overhead applied by the standard public API. + +When to use +----------- +Use ``ferro_ta.raw`` when: + +- You have benchmarked and confirmed that wrapper overhead is your bottleneck. +- Your inputs are already 1-D C-contiguous ``float64`` NumPy arrays. +- You do not need ``pandas.Series`` or ``polars.Series`` output. +- You understand the trade-off: no nice error messages, no index preservation. + +Stability +--------- +The raw API is **not guaranteed to be stable** across minor versions. +Function signatures follow the compiled Rust extension directly and may +change when the Rust layer changes. For a stable API use the public +``ferro_ta.*`` functions. + +Usage +----- +>>> import numpy as np +>>> from ferro_ta.core.raw import sma, ema, rsi +>>> +>>> close = np.random.rand(1000).astype(np.float64) +>>> result = sma(close, 20) # returns numpy.ndarray directly +>>> result2 = rsi(close, 14) +>>> result3 = ema(close, 20) + +Batch (Rust loop, 2-D input): +>>> data = np.random.rand(252, 100).astype(np.float64) +>>> sma_out = batch_sma(data, 20) # shape (252, 100) — Rust inner loop + +Available names +--------------- +All functions registered by the ``_ferro_ta`` extension module are accessible +from this namespace. In addition to the canonical imports below, you can +use the ``_ferro_ta`` module directly:: + + from ferro_ta._ferro_ta import sma # identical to ferro_ta.raw.sma +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Re-export everything from the compiled extension. +# The ``noqa: F401`` silences "imported but unused" warnings — these are +# intentional re-exports. +# --------------------------------------------------------------------------- +from ferro_ta._ferro_ta import ( # noqa: F401 + # Streaming classes (PyO3 classes) + StreamingATR, + StreamingBBands, + StreamingEMA, + StreamingMACD, + StreamingRSI, + StreamingSMA, + StreamingStoch, + StreamingSupertrend, + StreamingVWAP, + ad, + adosc, + adx, + adxr, + apo, + aroon, + aroonosc, + atr, + avgprice, + batch_ema, + batch_rsi, + batch_sma, + bbands, + beta, + bop, + cci, + cdl2crows, + cdl3blackcrows, + cdl3inside, + cdl3linestrike, + cdl3outside, + cdl3starsinsouth, + cdl3whitesoldiers, + cdlabandonedbaby, + cdladvanceblock, + cdlbelthold, + cdlbreakaway, + cdlclosingmarubozu, + cdlconcealbabyswall, + cdlcounterattack, + cdldarkcloudcover, + cdldoji, + cdldojistar, + cdldragonflydoji, + cdlengulfing, + cdleveningdojistar, + cdleveningstar, + cdlgapsidesidewhite, + cdlgravestonedoji, + cdlhammer, + cdlhangingman, + cdlharami, + cdlharamicross, + cdlhighwave, + cdlhikkake, + cdlhikkakemod, + cdlhomingpigeon, + cdlidentical3crows, + cdlinneck, + cdlinvertedhammer, + cdlkicking, + cdlkickingbylength, + cdlladderbottom, + cdllongleggeddoji, + cdllongline, + cdlmarubozu, + cdlmatchinglow, + cdlmathold, + cdlmorningdojistar, + cdlmorningstar, + cdlonneck, + cdlpiercing, + cdlrickshawman, + cdlrisefall3methods, + cdlseparatinglines, + cdlshootingstar, + cdlshortline, + cdlspinningtop, + cdlstalledpattern, + cdlsticksandwich, + cdltakuri, + cdltasukigap, + cdlthrusting, + cdltristar, + cdlunique3river, + cdlupsidegap2crows, + cdlxsidegap3methods, + # Extended indicators + chandelier_exit, + choppiness_index, + cmo, + correl, + dema, + donchian, + dx, + ema, + ht_dcperiod, + ht_dcphase, + ht_phasor, + ht_sine, + ht_trendline, + ht_trendmode, + hull_ma, + ichimoku, + kama, + keltner_channels, + linearreg, + linearreg_angle, + linearreg_intercept, + linearreg_slope, + ma, + macd, + macdext, + macdfix, + mama, + mavp, + medprice, + mfi, + midpoint, + midprice, + minus_di, + minus_dm, + mom, + natr, + obv, + pivot_points, + plus_di, + plus_dm, + ppo, + roc, + rocp, + rocr, + rocr100, + # Rolling math operators + rolling_max, + rolling_maxindex, + rolling_min, + rolling_minindex, + rolling_sum, + rsi, + sar, + sarext, + sma, + stddev, + stoch, + stochf, + stochrsi, + supertrend, + t3, + tema, + trange, + trima, + trix, + tsf, + typprice, + ultosc, + var, + vwap, + vwma, + wclprice, + willr, + wma, +) + +__all__ = [ + # Overlap + "sma", + "ema", + "wma", + "dema", + "tema", + "trima", + "kama", + "t3", + "bbands", + "macd", + "macdfix", + "macdext", + "sar", + "sarext", + "ma", + "mavp", + "mama", + "midpoint", + "midprice", + # Momentum + "rsi", + "mom", + "roc", + "rocp", + "rocr", + "rocr100", + "mfi", + "willr", + "adx", + "adxr", + "apo", + "ppo", + "cci", + "cmo", + "aroon", + "aroonosc", + "bop", + "stoch", + "stochf", + "stochrsi", + "ultosc", + "dx", + "plus_di", + "minus_di", + "plus_dm", + "minus_dm", + "trix", + # Volume + "ad", + "adosc", + "obv", + # Volatility + "atr", + "natr", + "trange", + # Statistics + "stddev", + "var", + "beta", + "correl", + "linearreg", + "linearreg_slope", + "linearreg_intercept", + "linearreg_angle", + "tsf", + # Price transforms + "avgprice", + "medprice", + "typprice", + "wclprice", + # Cycle + "ht_trendline", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendmode", + # Pattern recognition (all 61 CDL functions) + "cdl2crows", + "cdl3blackcrows", + "cdl3inside", + "cdl3linestrike", + "cdl3outside", + "cdl3starsinsouth", + "cdl3whitesoldiers", + "cdlabandonedbaby", + "cdladvanceblock", + "cdlbelthold", + "cdlbreakaway", + "cdlclosingmarubozu", + "cdlconcealbabyswall", + "cdlcounterattack", + "cdldarkcloudcover", + "cdldoji", + "cdldojistar", + "cdldragonflydoji", + "cdlengulfing", + "cdleveningdojistar", + "cdleveningstar", + "cdlgapsidesidewhite", + "cdlgravestonedoji", + "cdlhammer", + "cdlhangingman", + "cdlharami", + "cdlharamicross", + "cdlhighwave", + "cdlhikkake", + "cdlhikkakemod", + "cdlhomingpigeon", + "cdlidentical3crows", + "cdlinneck", + "cdlinvertedhammer", + "cdlkicking", + "cdlkickingbylength", + "cdlladderbottom", + "cdllongleggeddoji", + "cdllongline", + "cdlmarubozu", + "cdlmatchinglow", + "cdlmathold", + "cdlmorningdojistar", + "cdlmorningstar", + "cdlonneck", + "cdlpiercing", + "cdlrickshawman", + "cdlrisefall3methods", + "cdlseparatinglines", + "cdlshootingstar", + "cdlshortline", + "cdlspinningtop", + "cdlstalledpattern", + "cdlsticksandwich", + "cdltakuri", + "cdltasukigap", + "cdlthrusting", + "cdltristar", + "cdlunique3river", + "cdlupsidegap2crows", + "cdlxsidegap3methods", + # Batch (Rust-side 2-D loops — single GIL release) + "batch_sma", + "batch_ema", + "batch_rsi", + # Extended indicators (Rust) + "vwap", + "vwma", + "supertrend", + "donchian", + "choppiness_index", + "keltner_channels", + "hull_ma", + "chandelier_exit", + "ichimoku", + "pivot_points", + # Rolling math operators (Rust) + "rolling_sum", + "rolling_max", + "rolling_min", + "rolling_maxindex", + "rolling_minindex", + # Streaming classes (Rust PyO3) + "StreamingSMA", + "StreamingEMA", + "StreamingRSI", + "StreamingATR", + "StreamingBBands", + "StreamingMACD", + "StreamingStoch", + "StreamingVWAP", + "StreamingSupertrend", +] diff --git a/ferro-ta-main/python/ferro_ta/core/registry.py b/ferro-ta-main/python/ferro_ta/core/registry.py new file mode 100644 index 0000000..fe842f2 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/core/registry.py @@ -0,0 +1,199 @@ +""" +Plugin / Extension Registry +============================ + +A lightweight registry that allows users to register custom indicators and +call any indicator (built-in or custom) by name. + +Usage +----- +>>> import numpy as np +>>> import ferro_ta +>>> from ferro_ta.core.registry import register, run, get, list_indicators +>>> +>>> # Call a built-in indicator by name +>>> close = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]) +>>> result = run("SMA", close, timeperiod=3) +>>> +>>> # Register a custom indicator +>>> def MY_IND(close, timeperiod=10): +... \"\"\"Custom indicator: simple sum / timeperiod.\"\"\" +... import numpy as np +... out = np.full_like(close, np.nan) +... for i in range(timeperiod - 1, len(close)): +... out[i] = close[i - timeperiod + 1 : i + 1].sum() / timeperiod +... return out +>>> register("MY_IND", MY_IND) +>>> result = run("MY_IND", close, timeperiod=3) + +Writing a plugin +---------------- +A plugin function must: + +1. Accept at least one positional array argument (``close``, ``high``, etc.). +2. Accept keyword arguments for parameters (e.g. ``timeperiod=14``). +3. Return a single ``numpy.ndarray`` *or* a tuple of ``numpy.ndarray`` for + multi-output indicators. + +Example:: + + def DOUBLE_RSI(close, timeperiod=14, smooth=3): + import ferro_ta + rsi = ferro_ta.RSI(close, timeperiod=timeperiod) + return ferro_ta.SMA(rsi, timeperiod=smooth) + + from ferro_ta.core.registry import register + register("DOUBLE_RSI", DOUBLE_RSI) + +API +--- +register(name, func) — Register *func* under *name*. +unregister(name) — Remove a registered indicator. +get(name) — Return the callable for *name*. +run(name, *args, **kw) — Look up *name* and call it with *args* / **kw*. +list_indicators() — Return a sorted list of all registered names. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from ferro_ta.core.exceptions import FerroTAError + + +class FerroTARegistryError(FerroTAError): + """Raised when a registry lookup fails (unknown indicator name).""" + + +# --------------------------------------------------------------------------- +# Internal registry (module-level singleton dict) +# --------------------------------------------------------------------------- + +_REGISTRY: dict[str, Callable[..., Any]] = {} + + +def register(name: str, func: Callable[..., Any]) -> None: + """Register a callable under *name*. + + Parameters + ---------- + name: + Indicator name (case-sensitive; convention is ALL_CAPS for + compatibility with TA-Lib naming). + func: + A callable that accepts at least one array-like positional argument + and optional keyword arguments, and returns a ``numpy.ndarray`` or a + tuple of ``numpy.ndarray``. + + Raises + ------ + TypeError + If *func* is not callable. + """ + if not callable(func): + raise TypeError(f"Expected a callable for '{name}', got {type(func).__name__}") + _REGISTRY[name] = func + + +def unregister(name: str) -> None: + """Remove the indicator registered under *name*. + + Parameters + ---------- + name: + Indicator name to remove. + + Raises + ------ + FerroTARegistryError + If *name* is not in the registry. + """ + if name not in _REGISTRY: + raise FerroTARegistryError( + f"Indicator '{name}' is not registered. " + f"Available indicators: {sorted(_REGISTRY)[:10]}…" + ) + del _REGISTRY[name] + + +def get(name: str) -> Callable[..., Any]: + """Return the callable registered under *name*. + + Parameters + ---------- + name: + Indicator name (case-sensitive). + + Returns + ------- + Callable + The registered function. + + Raises + ------ + FerroTARegistryError + If *name* is not in the registry. + """ + if name not in _REGISTRY: + raise FerroTARegistryError( + f"Unknown indicator '{name}'. " + f"Use list_indicators() to see all registered names." + ) + return _REGISTRY[name] + + +def run(name: str, *args: Any, **kwargs: Any) -> Any: + """Look up *name* in the registry and call it with *args* / *kwargs*. + + Parameters + ---------- + name: + Indicator name (case-sensitive). + *args: + Positional arguments forwarded to the indicator function. + **kwargs: + Keyword arguments forwarded to the indicator function. + + Returns + ------- + numpy.ndarray or tuple of numpy.ndarray + Whatever the indicator function returns. + + Raises + ------ + FerroTARegistryError + If *name* is not in the registry. + """ + func = get(name) + return func(*args, **kwargs) + + +def list_indicators() -> list[str]: + """Return a sorted list of all registered indicator names. + + Returns + ------- + list of str + Sorted list of indicator names. + """ + return sorted(_REGISTRY) + + +# --------------------------------------------------------------------------- +# Auto-register all built-in indicators from ferro_ta.__all__ +# --------------------------------------------------------------------------- + + +def _register_builtins() -> None: + """Register every built-in indicator from ``ferro_ta.__all__``.""" + # Lazy import to avoid circular imports at module load time + import ferro_ta # noqa: PLC0415 + + for _name in ferro_ta.__all__: # type: ignore[attr-defined] + _fn = getattr(ferro_ta, _name, None) + if callable(_fn): + _REGISTRY[_name] = _fn + + +_register_builtins() diff --git a/ferro-ta-main/python/ferro_ta/data/__init__.py b/ferro-ta-main/python/ferro_ta/data/__init__.py new file mode 100644 index 0000000..34f9a96 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/data/__init__.py @@ -0,0 +1,17 @@ +""" +ferro_ta.data — Data ingestion, streaming, batch, and resampling utilities. + +Sub-modules +----------- +* :mod:`ferro_ta.data.streaming` — Streaming / incremental indicator state machines +* :mod:`ferro_ta.data.batch` — Batch execution across multiple series (2-D arrays) +* :mod:`ferro_ta.data.chunked` — Chunked / windowed processing for large datasets +* :mod:`ferro_ta.data.resampling` — OHLCV resampling and multi-timeframe support +* :mod:`ferro_ta.data.aggregation` — Tick / trade aggregation pipelines +* :mod:`ferro_ta.data.adapters` — DataFrame adapters (pandas, polars, numpy) + +Example usage:: + + from ferro_ta.data.streaming import StreamingSMA + from ferro_ta.data.batch import batch_sma +""" diff --git a/ferro-ta-main/python/ferro_ta/data/adapters.py b/ferro-ta-main/python/ferro_ta/data/adapters.py new file mode 100644 index 0000000..668e903 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/data/adapters.py @@ -0,0 +1,271 @@ +""" +ferro_ta.adapters — Market data adapters (pluggable). + +Defines an abstract ``DataAdapter`` interface and a concrete +``CsvAdapter`` that loads OHLCV data from a CSV file. Users can +subclass ``DataAdapter`` to add their own data sources (e.g. Alpaca, +Yahoo Finance, a database, etc.) while keeping the rest of the pipeline +unchanged. + +Classes +------- +DataAdapter + Abstract base class. Subclasses must implement :meth:`fetch`. + +CsvAdapter + Load OHLCV data from a CSV file. Requires pandas. + +InMemoryAdapter + Wrap an already-loaded pandas DataFrame or dict of arrays. + +Functions +--------- +register_adapter(name, adapter_class) + Register an adapter class under a name for lookup by string. + +get_adapter(name) + Return an adapter class previously registered under *name*. + +Examples +-------- +>>> from ferro_ta.data.adapters import InMemoryAdapter +>>> import numpy as np +>>> n = 50 +>>> rng = np.random.default_rng(0) +>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100 +>>> adapter = InMemoryAdapter({ +... "open": close, "high": close * 1.001, +... "low": close * 0.999, "close": close, +... "volume": np.ones(n) * 1000, +... }) +>>> ohlcv = adapter.fetch() +>>> "close" in ohlcv +True +""" + +from __future__ import annotations + +import abc +from typing import Any, Optional + +__all__ = [ + "DataAdapter", + "CsvAdapter", + "InMemoryAdapter", + "register_adapter", + "get_adapter", +] + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +_ADAPTER_REGISTRY: dict[str, type[DataAdapter]] = {} + + +def register_adapter(name: str, adapter_class: type[DataAdapter]) -> None: + """Register *adapter_class* under *name*. + + Parameters + ---------- + name : str + adapter_class : type — must subclass :class:`DataAdapter` + + Examples + -------- + >>> from ferro_ta.data.adapters import register_adapter, DataAdapter + >>> class MyAdapter(DataAdapter): + ... def fetch(self, **kwargs): return {} + >>> register_adapter("my_source", MyAdapter) + """ + if not issubclass(adapter_class, DataAdapter): + raise TypeError(f"{adapter_class!r} must subclass DataAdapter") + _ADAPTER_REGISTRY[name] = adapter_class + + +def get_adapter(name: str) -> type[DataAdapter]: + """Return the adapter class registered under *name*. + + Parameters + ---------- + name : str + + Raises + ------ + KeyError + If *name* is not registered. + """ + if name not in _ADAPTER_REGISTRY: + available = sorted(_ADAPTER_REGISTRY.keys()) + raise KeyError(f"No adapter registered under {name!r}. Available: {available}") + return _ADAPTER_REGISTRY[name] + + +# --------------------------------------------------------------------------- +# Abstract base +# --------------------------------------------------------------------------- + + +class DataAdapter(abc.ABC): + """Abstract base class for market data adapters. + + Subclasses must implement :meth:`fetch`, which returns OHLCV data as + a ``pandas.DataFrame`` (preferred) or a ``dict`` of numpy arrays. + + The contract for the returned data: + - Keys/columns: ``open``, ``high``, ``low``, ``close``, ``volume`` + (additional columns are allowed but not required). + - Values: numeric (float64-compatible). + - Index (for DataFrames): ideally a ``DatetimeIndex``; not required. + """ + + @abc.abstractmethod + def fetch(self, **kwargs: Any) -> Any: + """Return OHLCV data. + + Returns + ------- + pandas.DataFrame or dict + OHLCV data with keys/columns ``open``, ``high``, ``low``, + ``close``, ``volume``. + """ + + def __repr__(self) -> str: + return f"{type(self).__name__}()" + + +# --------------------------------------------------------------------------- +# CsvAdapter +# --------------------------------------------------------------------------- + + +class CsvAdapter(DataAdapter): + """Load OHLCV data from a CSV file. + + The CSV must have a header row. Column names are configurable. + + Parameters + ---------- + path : str + Path to the CSV file. + open_col, high_col, low_col, close_col, volume_col : str + CSV column names for each OHLCV field. + index_col : str or None + Column to use as the DataFrame index (e.g. ``'timestamp'``). + parse_dates : bool + If ``True`` (default), attempt to parse the index as dates. + + Requires + -------- + pandas + + Examples + -------- + >>> from ferro_ta.data.adapters import CsvAdapter + >>> # adapter = CsvAdapter("data.csv", index_col="date") + >>> # ohlcv = adapter.fetch() + """ + + def __init__( + self, + path: str, + *, + open_col: str = "open", + high_col: str = "high", + low_col: str = "low", + close_col: str = "close", + volume_col: str = "volume", + index_col: Optional[str] = None, + parse_dates: bool = True, + ) -> None: + self.path = path + self.open_col = open_col + self.high_col = high_col + self.low_col = low_col + self.close_col = close_col + self.volume_col = volume_col + self.index_col = index_col + self.parse_dates = parse_dates + + def fetch(self, **kwargs: Any) -> Any: + """Load the CSV and return a pandas DataFrame. + + Raises + ------ + ImportError + If pandas is not installed. + """ + try: + import pandas as pd + except ImportError as exc: + raise ImportError( + "pandas is required for CsvAdapter. Install with: pip install pandas" + ) from exc + df = pd.read_csv( + self.path, + index_col=self.index_col, + parse_dates=self.parse_dates if self.index_col is not None else False, + ) + # Rename columns if they differ from the standard names + rename = {} + for src, dst in [ + (self.open_col, "open"), + (self.high_col, "high"), + (self.low_col, "low"), + (self.close_col, "close"), + (self.volume_col, "volume"), + ]: + if src != dst and src in df.columns: + rename[src] = dst + if rename: + df = df.rename(columns=rename) + return df + + def __repr__(self) -> str: + return f"CsvAdapter(path={self.path!r})" + + +# --------------------------------------------------------------------------- +# InMemoryAdapter +# --------------------------------------------------------------------------- + + +class InMemoryAdapter(DataAdapter): + """Wrap already-loaded OHLCV data (dict or DataFrame). + + Parameters + ---------- + data : dict or pandas.DataFrame + OHLCV data. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.data.adapters import InMemoryAdapter + >>> n = 10 + >>> close = np.ones(n) * 100.0 + >>> adapter = InMemoryAdapter({"open": close, "high": close, + ... "low": close, "close": close, + ... "volume": close}) + >>> ohlcv = adapter.fetch() + >>> "close" in ohlcv + True + """ + + def __init__(self, data: Any) -> None: + self._data = data + + def fetch(self, **kwargs: Any) -> Any: + """Return the wrapped data as-is.""" + return self._data + + def __repr__(self) -> str: + return "InMemoryAdapter(...)" + + +# --------------------------------------------------------------------------- +# Register built-in adapters +# --------------------------------------------------------------------------- + +register_adapter("csv", CsvAdapter) +register_adapter("memory", InMemoryAdapter) diff --git a/ferro-ta-main/python/ferro_ta/data/aggregation.py b/ferro-ta-main/python/ferro_ta/data/aggregation.py new file mode 100644 index 0000000..bfd5f08 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/data/aggregation.py @@ -0,0 +1,238 @@ +""" +ferro_ta.aggregation — Tick and trade aggregation pipeline. + +Aggregates raw tick or trade data (streams of (timestamp, price, size)) into +OHLCV bars using three bar types: +- **time bars** — fixed time intervals (e.g. every 1 minute) +- **volume bars** — fixed volume threshold per bar +- **tick bars** — fixed number of ticks per bar + +The compute-intensive bar accumulation is implemented in Rust; this module +provides the Python-facing API with DataFrame support. + +Functions +--------- +aggregate_ticks(ticks, rule) + Aggregate a tick stream to OHLCV bars. The *rule* string specifies the + bar type and parameter: + - ``'time:'`` — e.g. ``'time:60'`` for 1-minute bars + - ``'volume:'`` — e.g. ``'volume:1000'`` for 1000-unit volume bars + - ``'tick:'`` — e.g. ``'tick:100'`` for 100-tick bars + +Rust backend +------------ +All accumulation logic delegates to:: + + ferro_ta._ferro_ta.aggregate_tick_bars + ferro_ta._ferro_ta.aggregate_volume_bars_ticks + ferro_ta._ferro_ta.aggregate_time_bars +""" + +from __future__ import annotations + +from typing import Any, Optional + +import numpy as np +from numpy.typing import NDArray + +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", + "TickAggregator", +] + +# --------------------------------------------------------------------------- +# _parse_rule +# --------------------------------------------------------------------------- + + +def _parse_rule(rule: str) -> tuple[str, float]: + """Parse a rule string into (bar_type, parameter). + + Supported formats:: + + 'time:60' → ('time', 60.0) + 'volume:1000' → ('volume', 1000.0) + 'tick:100' → ('tick', 100.0) + """ + parts = rule.split(":", 1) + if len(parts) != 2: + 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 FerroTAValueError( + f"Unknown bar type {bar_type!r}. Supported types: 'time', 'volume', 'tick'." + ) + try: + param = float(parts[1].strip()) + except ValueError as exc: + raise FerroTAValueError( + f"Cannot parse parameter {parts[1]!r} as a number in rule {rule!r}." + ) from exc + if param <= 0: + raise FerroTAValueError( + f"Rule parameter must be > 0, got {param} in rule {rule!r}." + ) + return bar_type, param + + +# --------------------------------------------------------------------------- +# aggregate_ticks +# --------------------------------------------------------------------------- + + +def aggregate_ticks( + ticks: Any, + rule: str = "tick:100", + *, + timestamp_col: str = "timestamp", + price_col: str = "price", + size_col: str = "size", +) -> Any: + """Aggregate tick/trade data into OHLCV bars. + + Parameters + ---------- + ticks : pandas.DataFrame, list of (timestamp, price, size), or dict of arrays + Tick data. Accepted formats: + + 1. **pandas DataFrame** with columns ``timestamp``, ``price``, ``size`` + (column names configurable via *_col* parameters). The timestamp + column must contain numeric Unix timestamps (seconds) for time bars. + 2. **list of tuples** ``[(ts, price, size), …]``. + 3. **dict** ``{'timestamp': array, 'price': array, 'size': array}``. + + rule : str + Bar specification: + - ``'tick:'`` — every N ticks become one bar (default ``'tick:100'``) + - ``'volume:'`` — every N units of volume become one bar + - ``'time:'`` — every N seconds become one bar + + timestamp_col, price_col, size_col : str + Column names when *ticks* is a DataFrame. + + Returns + ------- + pandas.DataFrame or tuple of numpy arrays + If a DataFrame was passed in (or pandas is available), returns a + DataFrame with columns ``open``, ``high``, ``low``, ``close``, + ``volume``, and (for time bars) ``timestamp``. + Otherwise returns a tuple ``(open, high, low, close, volume)``. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.data.aggregation import aggregate_ticks + >>> rng = np.random.default_rng(42) + >>> n = 500 + >>> price = 100 + np.cumsum(rng.normal(0, 0.1, n)) + >>> size = rng.uniform(10, 100, n) + >>> bars = aggregate_ticks({"price": price, "size": size}, rule="tick:50") + >>> len(bars["open"]) == n // 50 + (1 if n % 50 != 0 else 0) + True + """ + bar_type, param = _parse_rule(rule) + + # --- Normalise input --- + ts_arr: Optional[NDArray[np.float64]] = None + if isinstance(ticks, list): + # list of (ts, price, size) tuples + arr = np.ascontiguousarray(ticks, dtype=np.float64) + ts_arr = np.ascontiguousarray(arr[:, 0]) + price_arr = np.ascontiguousarray(arr[:, 1]) + size_arr = np.ascontiguousarray(arr[:, 2]) + elif isinstance(ticks, dict): + price_arr = _to_f64(ticks[price_col]) + size_arr = _to_f64(ticks[size_col]) + if timestamp_col in ticks: + ts_arr = _to_f64(ticks[timestamp_col]) + else: + # pandas DataFrame + try: + import pandas as pd + except ImportError as exc: + raise ImportError("pandas is required for DataFrame input") from exc + price_arr = _to_f64(ticks[price_col].values) + size_arr = _to_f64(ticks[size_col].values) + if timestamp_col in ticks.columns: + ts_arr = _to_f64(ticks[timestamp_col].values) + + # --- Aggregate --- + if bar_type == "tick": + ro, rh, rl, rc, rv = _rust_tick_bars(price_arr, size_arr, int(param)) + extra: Optional[NDArray] = None + elif bar_type == "volume": + ro, rh, rl, rc, rv = _rust_volume_bars_ticks(price_arr, size_arr, param) + extra = None + else: # time + if ts_arr is None: + 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) + extra = lbl + + # --- Return --- + try: + import pandas as pd + + df: dict[str, Any] = { + "open": ro, + "high": rh, + "low": rl, + "close": rc, + "volume": rv, + } + if extra is not None: + df["timestamp"] = (extra * int(param)).astype(np.int64) + return pd.DataFrame(df) + except ImportError: + return {"open": ro, "high": rh, "low": rl, "close": rc, "volume": rv} + + +# --------------------------------------------------------------------------- +# TickAggregator — class-based API +# --------------------------------------------------------------------------- + + +class TickAggregator: + """Class-based API for tick aggregation. + + Parameters + ---------- + rule : str + Bar specification (see :func:`aggregate_ticks`). + + Examples + -------- + >>> from ferro_ta.data.aggregation import TickAggregator + >>> agg = TickAggregator(rule="tick:50") + >>> import numpy as np + >>> rng = np.random.default_rng(0) + >>> ticks = {"price": rng.uniform(99, 101, 200), "size": rng.uniform(1, 10, 200)} + >>> bars = agg.aggregate(ticks) + >>> len(bars["open"]) >= 4 + True + """ + + def __init__(self, rule: str = "tick:100") -> None: + self.rule = rule + # Validate rule at construction time + _parse_rule(rule) + + def aggregate(self, ticks: Any, **kwargs: Any) -> Any: + """Aggregate *ticks* into bars. See :func:`aggregate_ticks`.""" + return aggregate_ticks(ticks, rule=self.rule, **kwargs) + + def __repr__(self) -> str: + return f"TickAggregator(rule={self.rule!r})" diff --git a/ferro-ta-main/python/ferro_ta/data/batch.py b/ferro-ta-main/python/ferro_ta/data/batch.py new file mode 100644 index 0000000..9412f7b --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/data/batch.py @@ -0,0 +1,446 @@ +""" +Batch Execution API — run indicators on multiple series in a single call. + +This module provides a 2-D batch API that accepts a 2-D numpy array +(n_samples × n_series) and applies an indicator to every column, returning +a 2-D output array of the same shape. + +For the most common indicators — SMA, EMA, RSI — the 2-D path is handled +entirely in Rust (a single GIL release for all columns). ``batch_apply`` +also dispatches these indicators to Rust when possible; other indicators +use the generic Python fallback path. + +Functions +--------- +batch_sma — SMA on every column of a 2-D array (Rust fast path for 2-D) +batch_ema — EMA on every column of a 2-D array (Rust fast path for 2-D) +batch_rsi — RSI on every column of a 2-D array (Rust fast path for 2-D) +batch_apply — Generic batch wrapper with Rust fast-path for SMA/EMA/RSI + +Usage +----- +>>> import numpy as np +>>> from ferro_ta.data.batch import batch_sma +>>> data = np.random.rand(100, 5) # 100 bars, 5 symbols +>>> result = batch_sma(data, timeperiod=14) +>>> result.shape +(100, 5) +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + batch_adx as _rust_batch_adx, +) +from ferro_ta._ferro_ta import ( + batch_atr as _rust_batch_atr, +) +from ferro_ta._ferro_ta import ( + batch_ema as _rust_batch_ema, +) +from ferro_ta._ferro_ta import ( + batch_rsi as _rust_batch_rsi, +) +from ferro_ta._ferro_ta import ( + batch_sma as _rust_batch_sma, +) +from ferro_ta._ferro_ta import ( + batch_stoch as _rust_batch_stoch, +) +from ferro_ta._ferro_ta import ( + run_close_indicators as _rust_run_close_indicators, +) +from ferro_ta._ferro_ta import ( + run_hlc_indicators as _rust_run_hlc_indicators, +) +from ferro_ta.core.registry import run as _registry_run +from ferro_ta.indicators.momentum import RSI +from ferro_ta.indicators.overlap import EMA, SMA + +__all__ = [ + "batch_sma", + "batch_ema", + "batch_rsi", + "batch_apply", + "compute_many", +] + +_CLOSE_FASTPATH_DEFAULTS: dict[str, int] = { + "SMA": 30, + "EMA": 30, + "RSI": 14, + "STDDEV": 5, + "VAR": 5, + "LINEARREG": 14, + "LINEARREG_SLOPE": 14, + "LINEARREG_INTERCEPT": 14, + "LINEARREG_ANGLE": 14, + "TSF": 14, +} + +_HLC_FASTPATH_DEFAULTS: dict[str, int] = { + "ATR": 14, + "NATR": 14, + "ADX": 14, + "ADXR": 14, + "CCI": 14, + "WILLR": 14, +} + +_BATCH_FASTPATH_DEFAULTS: dict[str, int] = { + "SMA": 30, + "EMA": 30, + "RSI": 14, +} + + +def _resolve_batch_fastpath( + fn: Callable[..., np.ndarray], + kwargs: dict[str, object], +) -> tuple[str, int] | None: + name = getattr(fn, "__name__", "").upper() + if name not in _BATCH_FASTPATH_DEFAULTS: + return None + if set(kwargs) - {"timeperiod"}: + return None + raw = kwargs.get("timeperiod", _BATCH_FASTPATH_DEFAULTS[name]) + if not isinstance(raw, int): + return None + return name, int(raw) + + +def _normalize_indicator_spec( + spec: str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object], +) -> tuple[str, dict[str, object], object | None]: + if isinstance(spec, str): + return spec, {}, None + if len(spec) == 2: + name, kwargs = spec + return name, kwargs, None + name, kwargs, out_key = spec + return name, kwargs, out_key + + +def _extract_timeperiod( + name: str, kwargs: dict[str, object], defaults: dict[str, int] +) -> int | None: + if name not in defaults: + return None + extra_keys = set(kwargs) - {"timeperiod"} + if extra_keys: + return None + raw_value = kwargs.get("timeperiod", defaults[name]) + if not isinstance(raw_value, int): + return None + return raw_value + + +def compute_many( + indicators: Sequence[ + str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object] + ], + *, + close: ArrayLike, + high: ArrayLike | None = None, + low: ArrayLike | None = None, + volume: ArrayLike | None = None, + parallel: bool = True, +) -> list[object]: + """Compute multiple indicators over the same arrays with grouped Rust calls. + + Supported single-output indicators are grouped into one Rust boundary crossing + per input-shape family (`close` only or `high/low/close`). Unsupported specs + fall back to the regular registry path, preserving behavior. + """ + + close_arr = np.ascontiguousarray(close, dtype=np.float64) + high_arr = None if high is None else np.ascontiguousarray(high, dtype=np.float64) + low_arr = None if low is None else np.ascontiguousarray(low, dtype=np.float64) + volume_arr = ( + None if volume is None else np.ascontiguousarray(volume, dtype=np.float64) + ) + + normalized = [_normalize_indicator_spec(spec) for spec in indicators] + results: list[object | None] = [None] * len(normalized) + + close_indices: list[int] = [] + close_names: list[str] = [] + close_periods: list[int] = [] + + hlc_indices: list[int] = [] + hlc_names: list[str] = [] + hlc_periods: list[int] = [] + + for idx, (name, kwargs, out_key) in enumerate(normalized): + if out_key is None: + close_period = _extract_timeperiod(name, kwargs, _CLOSE_FASTPATH_DEFAULTS) + if close_period is not None: + close_indices.append(idx) + close_names.append(name) + close_periods.append(close_period) + continue + + hlc_period = _extract_timeperiod(name, kwargs, _HLC_FASTPATH_DEFAULTS) + if hlc_period is not None and high_arr is not None and low_arr is not None: + hlc_indices.append(idx) + hlc_names.append(name) + hlc_periods.append(hlc_period) + continue + + if close_names: + grouped = _rust_run_close_indicators( + close_arr, close_names, close_periods, parallel + ) + for idx, value in zip(close_indices, grouped): + results[idx] = np.asarray(value, dtype=np.float64) + + if hlc_names and high_arr is not None and low_arr is not None: + grouped = _rust_run_hlc_indicators( + high_arr, low_arr, close_arr, hlc_names, hlc_periods, parallel + ) + for idx, value in zip(hlc_indices, grouped): + results[idx] = np.asarray(value, dtype=np.float64) + + for idx, (name, kwargs, _) in enumerate(normalized): + if results[idx] is not None: + continue + try: + results[idx] = _registry_run(name, close_arr, **kwargs) + continue + except (TypeError, Exception): + pass + + if high_arr is not None and low_arr is not None: + try: + results[idx] = _registry_run( + name, high_arr, low_arr, close_arr, **kwargs + ) + continue + except Exception: + pass + if volume_arr is not None: + try: + results[idx] = _registry_run( + name, high_arr, low_arr, close_arr, volume_arr, **kwargs + ) + continue + except Exception: + pass + + raise ValueError( + f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters." + ) + + return [result for result in results] + + +def batch_apply( + data: ArrayLike, + fn: Callable[..., np.ndarray], + **kwargs, +) -> np.ndarray: + """Apply any single-series indicator *fn* to every column of *data*. + + For recognized close-only indicators (SMA/EMA/RSI with default or + ``timeperiod`` argument only), this function dispatches to the Rust + batch kernels. Otherwise it falls back to a Python per-column loop. + + Parameters + ---------- + data : array-like, shape (n_samples,) or (n_samples, n_series) + Input data. If 1-D, the function is called directly on the array + and the result is returned without adding a column dimension. + fn : callable + Single-series indicator function (e.g. ``SMA``, ``EMA``, ``RSI``). + It must accept a 1-D array as first positional argument and return + a 1-D array of the same length. + **kwargs + Extra keyword arguments forwarded to *fn* (e.g. ``timeperiod=14``). + + Returns + ------- + numpy.ndarray + Same shape as *data*. Leading values are ``NaN`` for the warm-up + period, identical to calling *fn* on each column individually. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA + >>> from ferro_ta.data.batch import batch_apply + >>> data = np.random.rand(50, 3) + >>> out = batch_apply(data, SMA, timeperiod=5) + >>> out.shape + (50, 3) + """ + arr = np.asarray(data, dtype=np.float64) + if arr.ndim == 1: + return fn(arr, **kwargs) + if arr.ndim != 2: + raise ValueError(f"batch_apply expects 1-D or 2-D input; got {arr.ndim}-D") + + fastpath = _resolve_batch_fastpath(fn, kwargs) + if fastpath is not None: + indicator, timeperiod = fastpath + contiguous = np.ascontiguousarray(arr) + if indicator == "SMA": + return np.asarray(_rust_batch_sma(contiguous, timeperiod, True)) + if indicator == "EMA": + return np.asarray(_rust_batch_ema(contiguous, timeperiod, True)) + return np.asarray(_rust_batch_rsi(contiguous, timeperiod, True)) + + n_samples, n_series = arr.shape + result = np.empty((n_samples, n_series), dtype=np.float64) + for j in range(n_series): + result[:, j] = fn(arr[:, j], **kwargs) + return result + + +def batch_sma( + data: ArrayLike, + timeperiod: int = 30, + parallel: bool = True, +) -> np.ndarray: + """Simple Moving Average on every column of *data*. + + For 2-D inputs uses a Rust-side column loop (single GIL release). + When *parallel* is ``True`` (default), columns are processed in parallel + via Rayon across all available CPU cores. + 1-D input is passed directly to the single-series SMA. + + Parameters + ---------- + data : array-like, shape (n_samples,) or (n_samples, n_series) + timeperiod : int, default 30 + parallel : bool, default True + Enable multi-threaded parallel column processing via Rayon. + Set to ``False`` for small inputs where thread overhead dominates. + + Returns + ------- + numpy.ndarray — same shape as *data*. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.data.batch import batch_sma + >>> data = np.arange(1.0, 101.0).reshape(100, 1).repeat(3, axis=1) + >>> out = batch_sma(data, timeperiod=10) + >>> out.shape + (100, 3) + """ + arr = np.ascontiguousarray(data, dtype=np.float64) + if arr.ndim == 1: + return SMA(arr, timeperiod=timeperiod) + if arr.ndim != 2: + raise ValueError(f"batch_sma expects 1-D or 2-D input; got {arr.ndim}-D") + return np.asarray(_rust_batch_sma(arr, timeperiod, parallel)) + + +def batch_ema( + data: ArrayLike, + timeperiod: int = 30, + parallel: bool = True, +) -> np.ndarray: + """Exponential Moving Average on every column of *data*. + + For 2-D inputs uses a Rust-side column loop (single GIL release). + When *parallel* is ``True`` (default), columns are processed in parallel + via Rayon across all available CPU cores. + + Parameters + ---------- + data : array-like, shape (n_samples,) or (n_samples, n_series) + timeperiod : int, default 30 + parallel : bool, default True + Enable multi-threaded parallel column processing via Rayon. + + Returns + ------- + numpy.ndarray — same shape as *data*. + """ + arr = np.ascontiguousarray(data, dtype=np.float64) + if arr.ndim == 1: + return EMA(arr, timeperiod=timeperiod) + if arr.ndim != 2: + raise ValueError(f"batch_ema expects 1-D or 2-D input; got {arr.ndim}-D") + return np.asarray(_rust_batch_ema(arr, timeperiod, parallel)) + + +def batch_rsi( + data: ArrayLike, + timeperiod: int = 14, + parallel: bool = True, +) -> np.ndarray: + """Relative Strength Index on every column of *data*. + + For 2-D inputs uses a Rust-side column loop (single GIL release). + When *parallel* is ``True`` (default), columns are processed in parallel + via Rayon across all available CPU cores. + + Parameters + ---------- + data : array-like, shape (n_samples,) or (n_samples, n_series) + timeperiod : int, default 14 + parallel : bool, default True + Enable multi-threaded parallel column processing via Rayon. + + Returns + ------- + numpy.ndarray — same shape as *data*. Values in [0, 100]. + """ + arr = np.ascontiguousarray(data, dtype=np.float64) + if arr.ndim == 1: + return RSI(arr, timeperiod=timeperiod) + if arr.ndim != 2: + raise ValueError(f"batch_rsi expects 1-D or 2-D input; got {arr.ndim}-D") + return np.asarray(_rust_batch_rsi(arr, timeperiod, parallel)) + + +def batch_atr( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, + parallel: bool = True, +) -> np.ndarray: + h = np.ascontiguousarray(high, dtype=np.float64) + low_arr = np.ascontiguousarray(low, dtype=np.float64) + c = np.ascontiguousarray(close, dtype=np.float64) + return np.asarray(_rust_batch_atr(h, low_arr, c, timeperiod, parallel)) + + +def batch_stoch( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + slowk_period: int = 3, + slowd_period: int = 3, + parallel: bool = True, +) -> tuple[np.ndarray, np.ndarray]: + h = np.ascontiguousarray(high, dtype=np.float64) + low_arr = np.ascontiguousarray(low, dtype=np.float64) + c = np.ascontiguousarray(close, dtype=np.float64) + k, d = _rust_batch_stoch( + h, low_arr, c, fastk_period, slowk_period, slowd_period, parallel + ) + return np.asarray(k), np.asarray(d) + + +def batch_adx( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, + parallel: bool = True, +) -> np.ndarray: + h = np.ascontiguousarray(high, dtype=np.float64) + low_arr = np.ascontiguousarray(low, dtype=np.float64) + c = np.ascontiguousarray(close, dtype=np.float64) + return np.asarray(_rust_batch_adx(h, low_arr, c, timeperiod, parallel)) diff --git a/ferro-ta-main/python/ferro_ta/data/chunked.py b/ferro-ta-main/python/ferro_ta/data/chunked.py new file mode 100644 index 0000000..574aa25 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/data/chunked.py @@ -0,0 +1,251 @@ +""" +ferro_ta.chunked — Chunked / out-of-core processing. +==================================================== + +Run ferro-ta indicators on data that is too large to fit in memory by +processing it in overlapping chunks. Each chunk contains a warm-up prefix +(``overlap`` bars) from the previous chunk so that indicator state is +correct. After computing the indicator, the warm-up prefix is discarded and +the resulting arrays are concatenated. + +Functions +--------- +chunk_apply(fn, series, chunk_size, overlap, **fn_kwargs) + Run a single-input indicator function on a large series in chunks. + +make_chunk_ranges(n, chunk_size, overlap) + Return (start, end) index pairs for chunked processing. + +trim_overlap(chunk_out, overlap) + Discard the first *overlap* elements from an array. + +stitch_chunks(chunks) + Concatenate trimmed chunk outputs into one array. + +Rust backend +------------ + ferro_ta._ferro_ta.make_chunk_ranges + ferro_ta._ferro_ta.trim_overlap + ferro_ta._ferro_ta.stitch_chunks + ferro_ta._ferro_ta.chunk_apply_close_indicator + +Notes +----- +Indicators that rely on the full history (e.g. HT_TRENDLINE) cannot +produce exact results in chunked mode; the approximation improves with +larger ``overlap`` values. Indicators with a finite look-back period +(SMA, EMA, RSI, etc.) are exact when ``overlap >= timeperiod - 1``. + +For very large datasets or distributed execution, the optional Dask +integration (``dask.dataframe.map_partitions``) can be used directly +by passing any ferro-ta indicator function. See the example in the +docstring of ``chunk_apply``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + chunk_apply_close_indicator as _rust_chunk_apply_close_indicator, +) +from ferro_ta._ferro_ta import ( + make_chunk_ranges as _rust_make_chunk_ranges, +) +from ferro_ta._ferro_ta import ( + stitch_chunks as _rust_stitch_chunks, +) +from ferro_ta._ferro_ta import ( + trim_overlap as _rust_trim_overlap, +) +from ferro_ta._utils import _to_f64 + +__all__ = [ + "chunk_apply", + "make_chunk_ranges", + "trim_overlap", + "stitch_chunks", +] + +_FASTPATH_DEFAULT_PERIODS: dict[str, int] = { + "SMA": 30, + "EMA": 30, + "RSI": 14, +} + + +def _resolve_chunk_fastpath( + fn: Callable[..., Any], fn_kwargs: dict[str, Any] +) -> tuple[str, int] | None: + name = getattr(fn, "__name__", "").upper() + if name not in _FASTPATH_DEFAULT_PERIODS: + return None + if set(fn_kwargs) - {"timeperiod"}: + return None + raw = fn_kwargs.get("timeperiod", _FASTPATH_DEFAULT_PERIODS[name]) + if not isinstance(raw, int): + return None + return name, int(raw) + + +def make_chunk_ranges( + n: int, + chunk_size: int, + overlap: int, +) -> NDArray[np.int64]: + """Compute start/end index pairs for chunked processing. + + Parameters + ---------- + n : int — total length of the series + chunk_size : int — desired output bars per chunk (>= 1) + overlap : int — warm-up bars prepended to each chunk (>= 0) + + Returns + ------- + numpy.ndarray of int64 with shape (n_chunks, 2) — each row is + ``[start_index, end_index)`` of the slice to pass to the indicator. + + Examples + -------- + >>> from ferro_ta.data.chunked import make_chunk_ranges + >>> make_chunk_ranges(10, 4, 2) + array([[ 0, 6], + [ 4, 10]]) + """ + raw = np.asarray( + _rust_make_chunk_ranges(int(n), int(chunk_size), int(overlap)), + dtype=np.int64, + ) + if len(raw) == 0: + return raw.reshape(0, 2) + return raw.reshape(-1, 2) + + +def trim_overlap( + chunk_out: ArrayLike, + overlap: int, +) -> NDArray[np.float64]: + """Discard the first *overlap* elements from a chunk's indicator output. + + Parameters + ---------- + chunk_out : array-like — indicator output for a chunk + overlap : int — number of leading warm-up elements to discard + + Returns + ------- + numpy.ndarray of float64 — the remaining elements + """ + arr = np.ascontiguousarray(_to_f64(chunk_out)) + return np.asarray(_rust_trim_overlap(arr, int(overlap)), dtype=np.float64) + + +def stitch_chunks( + chunks: list[ArrayLike], +) -> NDArray[np.float64]: + """Concatenate trimmed chunk outputs into a single array. + + Parameters + ---------- + chunks : list of array-like — trimmed indicator outputs + + Returns + ------- + numpy.ndarray of float64 — full concatenated result + """ + converted = [np.ascontiguousarray(_to_f64(c)) for c in chunks] + return np.asarray(_rust_stitch_chunks(converted), dtype=np.float64) + + +def chunk_apply( + fn: Callable[..., Any], + series: ArrayLike, + chunk_size: int = 10_000, + overlap: int = 100, + **fn_kwargs: Any, +) -> NDArray[np.float64]: + """Run a 1-D indicator function on a large series in overlapping chunks. + + Parameters + ---------- + fn : callable — indicator function with signature ``fn(series, **kwargs)`` + that accepts a 1-D numpy array and returns a 1-D numpy array of the + same length. Examples: ``ferro_ta.SMA``, ``ferro_ta.RSI``. + series : array-like — the full (possibly large) input series + chunk_size : int — output bars per chunk (default 10 000). Tune this + for memory/performance. + overlap : int — warm-up bars prepended to each chunk (default 100). + Set to at least ``timeperiod - 1`` for the indicator to be accurate. + **fn_kwargs : extra keyword arguments forwarded to *fn* on every chunk. + + Returns + ------- + numpy.ndarray of float64 — full indicator output over the entire series. + + Notes + ----- + For Dask DataFrames, call ``dask.dataframe.map_partitions`` directly:: + + import dask.dataframe as dd + from ferro_ta import RSI + + ddf = dd.from_pandas(pd.Series(close), npartitions=4) + result = ddf.map_partitions(lambda s: pd.Series(RSI(s.values))) + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA + >>> from ferro_ta.data.chunked import chunk_apply + >>> rng = np.random.default_rng(0) + >>> big_series = rng.standard_normal(50_000).cumsum() + 100 + >>> out = chunk_apply(SMA, big_series, chunk_size=5000, overlap=30, + ... timeperiod=20) + >>> out.shape + (50000,) + """ + s = _to_f64(series) + n = len(s) + if n == 0: + return np.empty(0, dtype=np.float64) + + fastpath = _resolve_chunk_fastpath(fn, fn_kwargs) + if fastpath is not None: + indicator, timeperiod = fastpath + return np.asarray( + _rust_chunk_apply_close_indicator( + np.ascontiguousarray(s), + indicator, + int(timeperiod), + int(chunk_size), + int(overlap), + ), + dtype=np.float64, + ) + + ranges = make_chunk_ranges(n, chunk_size, overlap) + if len(ranges) == 0: + result = fn(s, **fn_kwargs) + return np.asarray(result, dtype=np.float64) + + trimmed_chunks: list[NDArray[np.float64]] = [] + + for i, (start, end) in enumerate(ranges): + chunk = s[int(start) : int(end)] + result = fn(chunk, **fn_kwargs) + result_arr = np.asarray(result, dtype=np.float64) + + # Determine how many leading bars to discard: + # - first chunk: keep everything (no prior overlap) + # - subsequent chunks: discard the leading `overlap` bars + discard = 0 if i == 0 else int(overlap) + + trimmed = trim_overlap(result_arr, discard) + trimmed_chunks.append(trimmed) + + return stitch_chunks(trimmed_chunks) # type: ignore[arg-type] diff --git a/ferro-ta-main/python/ferro_ta/data/resampling.py b/ferro-ta-main/python/ferro_ta/data/resampling.py new file mode 100644 index 0000000..ebaded1 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/data/resampling.py @@ -0,0 +1,278 @@ +""" +ferro_ta.resampling — OHLCV resampling and multi-timeframe API. + +Provides functions to resample OHLCV data into coarser time bars or volume +bars, and a multi-timeframe helper that runs an indicator on two or more +resampled timeframes in one call. + +The heavy OHLCV aggregation logic lives in the Rust backend +(``_ferro_ta.volume_bars`` and ``_ferro_ta.ohlcv_agg``); this module provides +the Python-facing API with: +- Time-based resampling via pandas (requires ``pandas``). +- Volume-bar resampling via Rust (no extra dependencies). +- Multi-timeframe helper that returns a dict of DataFrames. + +Functions +--------- +resample(ohlcv, rule, *, label='right', closed='right') + Resample a pandas OHLCV DataFrame by a time rule (e.g. ``'5min'``, + ``'1h'``). Requires pandas. + +volume_bars(ohlcv, volume_threshold) + Aggregate OHLCV data into volume bars using the Rust backend. + Accepts a pandas DataFrame or separate numpy arrays. + +multi_timeframe(ohlcv, rules, *, indicator=None, indicator_kwargs=None) + Resample OHLCV to multiple timeframes and optionally run an indicator + on each. Returns a dict mapping each rule to a DataFrame (or to an + indicator result when *indicator* is given). + +Rust backend +------------ +All bar-accumulation logic delegates to:: + + ferro_ta._ferro_ta.volume_bars + ferro_ta._ferro_ta.ohlcv_agg +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional + +from ferro_ta._ferro_ta import volume_bars as _rust_volume_bars +from ferro_ta._utils import _to_f64 + +__all__ = [ + "resample", + "volume_bars", + "multi_timeframe", +] + + +# --------------------------------------------------------------------------- +# resample — time-based resampling (pandas required) +# --------------------------------------------------------------------------- + + +def resample( + ohlcv: Any, + rule: str, + *, + label: str = "right", + closed: str = "right", +) -> Any: + """Resample an OHLCV DataFrame to a coarser time rule. + + Uses ``pandas.DataFrame.resample`` under the hood; the index must be a + ``DatetimeIndex`` (timezone-aware or naive). + + Parameters + ---------- + ohlcv : pandas.DataFrame + Must have columns ``open``, ``high``, ``low``, ``close``, ``volume`` + (case-sensitive; use the column-name helpers in :mod:`ferro_ta._utils` + if your column names differ). Index must be a ``DatetimeIndex``. + rule : str + Pandas offset alias (e.g. ``'5min'``, ``'1h'``, ``'1D'``). + label : str + Which bin edge to label the bucket with (``'left'`` or ``'right'``). + Default ``'right'``. + closed : str + Which side of the interval is closed (``'left'`` or ``'right'``). + Default ``'right'``. + + Returns + ------- + pandas.DataFrame + Resampled OHLCV DataFrame with the same column names. + + Raises + ------ + ImportError + If pandas is not installed. + ValueError + If required columns are missing or the index is not a DatetimeIndex. + + Examples + -------- + >>> import pandas as pd, numpy as np + >>> from ferro_ta.data.resampling import resample + >>> idx = pd.date_range("2024-01-01", periods=60, freq="1min") + >>> df = pd.DataFrame({ + ... "open": np.random.rand(60) + 100, + ... "high": np.random.rand(60) + 101, + ... "low": np.random.rand(60) + 99, + ... "close": np.random.rand(60) + 100, + ... "volume": np.random.randint(100, 1000, 60).astype(float), + ... }, index=idx) + >>> df5 = resample(df, "5min") + >>> df5.shape[0] + 12 + """ + try: + import pandas as pd + except ImportError as exc: + raise ImportError( + "pandas is required for time-based resampling. " + "Install it with: pip install pandas" + ) from exc + + required = {"open", "high", "low", "close", "volume"} + missing = required - set(ohlcv.columns) + if missing: + raise ValueError(f"OHLCV DataFrame missing columns: {missing}") + + if not isinstance(ohlcv.index, pd.DatetimeIndex): + raise ValueError( + "ohlcv.index must be a pandas DatetimeIndex for time-based resampling." + ) + + agg = { + "open": "first", + "high": "max", + "low": "min", + "close": "last", + "volume": "sum", + } + return ohlcv.resample(rule, label=label, closed=closed).agg(agg).dropna(how="all") + + +# --------------------------------------------------------------------------- +# volume_bars — volume-based resampling (Rust backend) +# --------------------------------------------------------------------------- + + +def volume_bars( + ohlcv: Any, + volume_threshold: float, + *, + open_col: str = "open", + high_col: str = "high", + low_col: str = "low", + close_col: str = "close", + volume_col: str = "volume", +) -> Any: + """Aggregate OHLCV data into volume bars using the Rust backend. + + Each output bar accumulates input bars until ``volume_threshold`` units of + volume have been consumed. + + Parameters + ---------- + ohlcv : pandas.DataFrame or tuple of arrays + Either a pandas DataFrame with OHLCV columns, or a tuple + ``(open, high, low, close, volume)`` of array-like objects. + volume_threshold : float + Target volume per output bar (must be > 0). + open_col, high_col, low_col, close_col, volume_col : str + Column names when ``ohlcv`` is a DataFrame. + + Returns + ------- + pandas.DataFrame or tuple of numpy arrays + If a DataFrame was passed in, returns a DataFrame with the same column + names. Otherwise returns a tuple + ``(open, high, low, close, volume)`` of numpy arrays. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.data.resampling import volume_bars + >>> n = 100 + >>> o = np.random.rand(n) + 100 + >>> h = o + np.random.rand(n) + >>> l = o - np.random.rand(n) + >>> c = np.random.rand(n) + 100 + >>> v = np.random.randint(50, 150, n).astype(float) + >>> bars = volume_bars((o, h, l, c, v), volume_threshold=500) + >>> len(bars[0]) > 0 + True + """ + if isinstance(ohlcv, tuple): + o, h, low, c, v = (_to_f64(x) for x in ohlcv) + return _rust_volume_bars(o, h, low, c, v, float(volume_threshold)) + + # pandas DataFrame path + try: + import pandas as pd + except ImportError as exc: + raise ImportError("pandas is required when passing a DataFrame") from exc + + o = _to_f64(ohlcv[open_col].values) + h = _to_f64(ohlcv[high_col].values) + low = _to_f64(ohlcv[low_col].values) + c = _to_f64(ohlcv[close_col].values) + v = _to_f64(ohlcv[volume_col].values) + ro, rh, rl, rc, rv = _rust_volume_bars(o, h, low, c, v, float(volume_threshold)) + return pd.DataFrame( + { + open_col: ro, + high_col: rh, + low_col: rl, + close_col: rc, + volume_col: rv, + } + ) + + +# --------------------------------------------------------------------------- +# multi_timeframe — run indicator on multiple resampled timeframes +# --------------------------------------------------------------------------- + + +def multi_timeframe( + ohlcv: Any, + rules: list[str], + *, + indicator: Optional[Callable[..., Any]] = None, + indicator_kwargs: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Resample OHLCV to multiple timeframes and optionally run an indicator. + + Parameters + ---------- + ohlcv : pandas.DataFrame + OHLCV data with a ``DatetimeIndex``. + rules : list of str + Pandas offset aliases, e.g. ``['5min', '1h']``. + indicator : callable, optional + A function ``indicator(close, **kwargs) -> array`` (or multi-output). + When provided it is called on the resampled ``close`` column for each + rule, and the result is stored in the returned dict instead of the + full DataFrame. + indicator_kwargs : dict, optional + Keyword arguments forwarded to *indicator*. + + Returns + ------- + dict + Mapping from each rule string to: + - a resampled pandas DataFrame when *indicator* is ``None``, or + - the indicator output (numpy array or tuple) when *indicator* is given. + + Examples + -------- + >>> import pandas as pd, numpy as np + >>> from ferro_ta import RSI + >>> from ferro_ta.data.resampling import multi_timeframe + >>> idx = pd.date_range("2024-01-01", periods=200, freq="1min") + >>> close = np.cumprod(1 + np.random.randn(200) * 0.001) * 100 + >>> df = pd.DataFrame({ + ... "open": close, "high": close * 1.001, "low": close * 0.999, + ... "close": close, "volume": np.ones(200) * 1000, + ... }, index=idx) + >>> result = multi_timeframe(df, ["5min", "15min"], indicator=RSI, + ... indicator_kwargs={"timeperiod": 14}) + >>> sorted(result.keys()) + ['15min', '5min'] + """ + kw = indicator_kwargs or {} + out: dict[str, Any] = {} + for rule in rules: + df_r = resample(ohlcv, rule) + if indicator is not None: + out[rule] = indicator(_to_f64(df_r["close"].values), **kw) + else: + out[rule] = df_r + return out diff --git a/ferro-ta-main/python/ferro_ta/data/streaming.py b/ferro-ta-main/python/ferro_ta/data/streaming.py new file mode 100644 index 0000000..30e20f8 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/data/streaming.py @@ -0,0 +1,69 @@ +""" +Streaming / Incremental Indicators — bar-by-bar stateful classes. + +All streaming classes are implemented in Rust (PyO3) for maximum performance. +The Python module re-exports the Rust classes from the ``_ferro_ta`` extension. +The extension must be built; there is no Python fallback. + +Usage +----- +>>> from ferro_ta.data.streaming import StreamingSMA, StreamingEMA, StreamingRSI +>>> import numpy as np +>>> sma = StreamingSMA(period=3) +>>> for close in [10.0, 11.0, 12.0, 13.0, 14.0]: +... val = sma.update(close) +... print(f"{close} → {val:.4f}" if not np.isnan(val) else f"{close} → NaN") +10.0 → NaN +11.0 → NaN +12.0 → 11.0000 +13.0 → 12.0000 +14.0 → 13.0000 + +Available classes +----------------- +StreamingSMA — Simple Moving Average +StreamingEMA — Exponential Moving Average +StreamingRSI — Relative Strength Index (Wilder seeding) +StreamingATR — Average True Range (Wilder seeding) +StreamingBBands — Bollinger Bands (upper, middle, lower) +StreamingMACD — MACD line, signal, histogram +StreamingStoch — Slow Stochastic (slowk, slowd) +StreamingVWAP — Volume Weighted Average Price (cumulative) +StreamingSupertrend — ATR-based Supertrend + +Rust backend +------------ +All classes are PyO3 classes compiled into the ``_ferro_ta`` extension module. +Import them directly from the extension for zero-overhead access:: + + from ferro_ta._ferro_ta import StreamingSMA +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Import Rust-backed streaming classes from the compiled extension. +# --------------------------------------------------------------------------- +from ferro_ta._ferro_ta import ( # noqa: F401 + StreamingATR, + StreamingBBands, + StreamingEMA, + StreamingMACD, + StreamingRSI, + StreamingSMA, + StreamingStoch, + StreamingSupertrend, + StreamingVWAP, +) + +__all__ = [ + "StreamingSMA", + "StreamingEMA", + "StreamingRSI", + "StreamingATR", + "StreamingBBands", + "StreamingMACD", + "StreamingStoch", + "StreamingVWAP", + "StreamingSupertrend", +] diff --git a/ferro-ta-main/python/ferro_ta/indicators/__init__.py b/ferro-ta-main/python/ferro_ta/indicators/__init__.py new file mode 100644 index 0000000..93e480b --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/__init__.py @@ -0,0 +1,25 @@ +""" +ferro_ta.indicators — Technical indicator functions. + +Sub-modules +----------- +* :mod:`ferro_ta.indicators.momentum` — Momentum Indicators (RSI, STOCH, ADX, CCI, …) +* :mod:`ferro_ta.indicators.overlap` — Overlap Studies (SMA, EMA, BBANDS, MACD, …) +* :mod:`ferro_ta.indicators.volatility` — Volatility Indicators (ATR, NATR, TRANGE) +* :mod:`ferro_ta.indicators.volume` — Volume Indicators (AD, ADOSC, OBV) +* :mod:`ferro_ta.indicators.statistic` — Statistic Functions (STDDEV, VAR, LINEARREG, …) +* :mod:`ferro_ta.indicators.price_transform` — Price Transforms (AVGPRICE, MEDPRICE, …) +* :mod:`ferro_ta.indicators.pattern` — Candlestick Pattern Recognition (CDL*) +* :mod:`ferro_ta.indicators.cycle` — Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, …) +* :mod:`ferro_ta.indicators.math_ops` — Math Operators/Transforms (ADD, SUB, SUM, …) +* :mod:`ferro_ta.indicators.extended` — Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, …) + +All indicators are also importable directly from :mod:`ferro_ta`:: + + import ferro_ta + result = ferro_ta.RSI(close, timeperiod=14) + + # or directly from the sub-module: + from ferro_ta.indicators.momentum import RSI + result = RSI(close, timeperiod=14) +""" diff --git a/ferro-ta-main/python/ferro_ta/indicators/cycle.py b/ferro-ta-main/python/ferro_ta/indicators/cycle.py new file mode 100644 index 0000000..1f54ef6 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/cycle.py @@ -0,0 +1,187 @@ +""" +Cycle Indicators — Hilbert Transform-based cycle analysis. + +All functions use a 63-bar lookback period (first 63 values are NaN). + +Functions +--------- +HT_TRENDLINE — Hilbert Transform - Instantaneous Trendline +HT_DCPERIOD — Hilbert Transform - Dominant Cycle Period +HT_DCPHASE — Hilbert Transform - Dominant Cycle Phase +HT_PHASOR — Hilbert Transform - Phasor Components (returns inphase, quadrature) +HT_SINE — Hilbert Transform - SineWave (returns sine, leadsine) +HT_TRENDMODE — Hilbert Transform - Trend vs Cycle Mode (1=trend, 0=cycle) +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + ht_dcperiod as _ht_dcperiod, +) +from ferro_ta._ferro_ta import ( + ht_dcphase as _ht_dcphase, +) +from ferro_ta._ferro_ta import ( + ht_phasor as _ht_phasor, +) +from ferro_ta._ferro_ta import ( + ht_sine as _ht_sine, +) +from ferro_ta._ferro_ta import ( + ht_trendline as _ht_trendline, +) +from ferro_ta._ferro_ta import ( + ht_trendmode as _ht_trendmode, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def HT_TRENDLINE(close: ArrayLike) -> np.ndarray: + """Hilbert Transform - Instantaneous Trendline. + + Computes the underlying trend of the price series using the Hilbert + Transform. The trendline is the dominant-cycle-period average of the + smoothed price. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Trendline values; first 63 entries are ``NaN``. + """ + try: + return _ht_trendline(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_DCPERIOD(close: ArrayLike) -> np.ndarray: + """Hilbert Transform - Dominant Cycle Period. + + Estimates the current dominant cycle period in bars using the Hilbert + Transform. Values are smoothed and clamped to [6, 50]. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Dominant cycle period values; first 63 entries are ``NaN``. + """ + try: + return _ht_dcperiod(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_DCPHASE(close: ArrayLike) -> np.ndarray: + """Hilbert Transform - Dominant Cycle Phase. + + Returns the instantaneous phase (in degrees) of the dominant cycle. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Phase values in degrees; first 63 entries are ``NaN``. + """ + try: + return _ht_dcphase(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_PHASOR( + close: ArrayLike, +) -> tuple[np.ndarray, np.ndarray]: + """Hilbert Transform - Phasor Components. + + Returns the In-Phase (I) and Quadrature (Q) components of the Hilbert + Transform. These represent the real and imaginary parts of the analytic + signal derived from the price series. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(inphase, quadrature)`` — two arrays; first 63 entries are ``NaN``. + """ + try: + return _ht_phasor(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_SINE( + close: ArrayLike, +) -> tuple[np.ndarray, np.ndarray]: + """Hilbert Transform - SineWave. + + Returns the sine and lead-sine (45-degree lead) of the dominant cycle + phase. Used to detect cycle turning points. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(sine, leadsine)`` — two arrays; first 63 entries are ``NaN``. + """ + try: + return _ht_sine(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_TRENDMODE(close: ArrayLike) -> np.ndarray: + """Hilbert Transform - Trend vs Cycle Mode. + + Returns 1 when the market is in a trending mode (dominant cycle period + below 20 bars) and 0 when in a cycling mode. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray[int32] + Array of 1 (trending) or 0 (cycling). + """ + try: + return _ht_trendmode(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "HT_TRENDLINE", + "HT_DCPERIOD", + "HT_DCPHASE", + "HT_PHASOR", + "HT_SINE", + "HT_TRENDMODE", +] diff --git a/ferro-ta-main/python/ferro_ta/indicators/extended.py b/ferro-ta-main/python/ferro_ta/indicators/extended.py new file mode 100644 index 0000000..1c4274e --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/extended.py @@ -0,0 +1,498 @@ +""" +Extended Indicators — Popular indicators not in the TA-Lib standard set. + +All indicator logic is implemented in Rust (PyO3) for maximum performance. +This module provides the public Python API with: +- Input validation +- ``_to_f64`` conversion +- pandas/polars-compatible return values (numpy arrays) + +Functions +--------- +VWAP — Volume Weighted Average Price (cumulative or rolling) +SUPERTREND — ATR-based trend-following signal +ICHIMOKU — Ichimoku Cloud +DONCHIAN — Donchian Channels +PIVOT_POINTS — Classic / Fibonacci / Camarilla pivot levels +KELTNER_CHANNELS — EMA ± ATR bands +HULL_MA — Hull Moving Average (WMA-based) +CHANDELIER_EXIT — ATR-based stop-loss / exit levels +VWMA — Volume Weighted Moving Average +CHOPPINESS_INDEX — Market choppiness / trending strength index + +Rust backend +------------ +All computations delegate to Rust functions in the ``_ferro_ta`` extension:: + + from ferro_ta._ferro_ta import supertrend, donchian, vwap, ... +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +# --------------------------------------------------------------------------- +# Import Rust implementations +# --------------------------------------------------------------------------- +from ferro_ta._ferro_ta import ( + chandelier_exit as _rust_chandelier_exit, +) +from ferro_ta._ferro_ta import ( + choppiness_index as _rust_choppiness_index, +) +from ferro_ta._ferro_ta import ( + donchian as _rust_donchian, +) +from ferro_ta._ferro_ta import ( + hull_ma as _rust_hull_ma, +) +from ferro_ta._ferro_ta import ( + ichimoku as _rust_ichimoku, +) +from ferro_ta._ferro_ta import ( + keltner_channels as _rust_keltner_channels, +) +from ferro_ta._ferro_ta import ( + pivot_points as _rust_pivot_points, +) +from ferro_ta._ferro_ta import ( + supertrend as _rust_supertrend, +) +from ferro_ta._ferro_ta import ( + vwap as _rust_vwap, +) +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( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 0, +) -> np.ndarray: + """Volume Weighted Average Price. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volumes. + timeperiod : int, optional + Rolling window length. ``0`` (default) computes a cumulative VWAP + from bar 0 (session VWAP). Any value ``>= 1`` uses a rolling window + of that length; the first ``timeperiod - 1`` values are ``NaN``. + + Returns + ------- + numpy.ndarray + Array of VWAP values. + + Notes + ----- + Typical price is used: ``(high + low + close) / 3``. + Implemented in Rust for maximum performance. + """ + if timeperiod < 0: + raise FerroTAValueError("timeperiod must be >= 0 for VWAP") + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + v = _to_f64(volume) + try: + return np.asarray(_rust_vwap(h, lo, c, v, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def SUPERTREND( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 7, + multiplier: float = 3.0, +) -> tuple[np.ndarray, np.ndarray]: + """Supertrend indicator. + + An ATR-based trend-following indicator. Returns the Supertrend line and a + direction array. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + ATR period (default 7). + multiplier : float, optional + ATR multiplier for band width (default 3.0). + + Returns + ------- + supertrend : numpy.ndarray + The Supertrend line values. ``NaN`` during the warmup period. + direction : numpy.ndarray + ``1`` = uptrend (price above Supertrend), ``-1`` = downtrend. + ``0`` during warmup. + + Notes + ----- + Implemented in Rust — the sequential band-adjustment loop that was + previously a Python bottleneck now runs at native speed. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SUPERTREND + >>> h = np.array([10.0, 11.0, 12.0, 11.0, 10.0, 9.0, 8.0, 9.0, 10.0, 11.0, + ... 12.0, 13.0, 14.0, 13.0, 12.0]) + >>> l = h - 1.0 + >>> c = (h + l) / 2.0 + >>> st, dir_ = SUPERTREND(h, l, c) + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier) + except ValueError as e: + _normalize_rust_error(e) + return np.asarray(st), np.asarray(d) + + +def ICHIMOKU( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + tenkan_period: int = 9, + kijun_period: int = 26, + senkou_b_period: int = 52, + displacement: int = 26, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Ichimoku Cloud (Ichimoku Kinko Hyo). + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + tenkan_period : int, default 9 + Conversion line (Tenkan-sen) period. + kijun_period : int, default 26 + Base line (Kijun-sen) period. + senkou_b_period : int, default 52 + Leading Span B period. + displacement : int, default 26 + Displacement / cloud offset for Senkou A & B. + + Returns + ------- + tenkan, kijun, senkou_a, senkou_b, chikou : numpy.ndarray + Each is a 1-D float64 array of the same length as the inputs. + + Notes + ----- + Implemented in Rust with O(n) monotonic deque for all rolling windows. + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + t, k, sa, sb, ch = _rust_ichimoku( + h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement + ) + except ValueError as e: + _normalize_rust_error(e) + return ( + np.asarray(t), + np.asarray(k), + np.asarray(sa), + np.asarray(sb), + np.asarray(ch), + ) + + +def DONCHIAN( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 20, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Donchian Channels — rolling highest high / lowest low. + + Parameters + ---------- + high : array-like + low : array-like + timeperiod : int, default 20 + + Returns + ------- + upper, middle, lower : numpy.ndarray + Rolling highest high, midpoint, and lowest low. + + Notes + ----- + Implemented in Rust with O(n) monotonic deque (no Python loop). + """ + h = _to_f64(high) + lo = _to_f64(low) + try: + upper, middle, lower = _rust_donchian(h, lo, timeperiod) + except ValueError as e: + _normalize_rust_error(e) + return np.asarray(upper), np.asarray(middle), np.asarray(lower) + + +def PIVOT_POINTS( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + method: str = "classic", +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Pivot Points — support / resistance levels. + + Computes pivot points for each bar using the *previous bar's* H/L/C. + The first bar output is NaN. + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + method : {'classic', 'fibonacci', 'camarilla'}, default 'classic' + + Returns + ------- + pivot, r1, s1, r2, s2 : numpy.ndarray + + Notes + ----- + **Classic**: P=(H+L+C)/3; R1=2P−L; S1=2P−H; R2=P+(H−L); S2=P−(H−L) + + **Fibonacci**: P=(H+L+C)/3; R1=P+0.382*(H−L); S1=P−0.382*(H−L); + R2=P+0.618*(H−L); S2=P−0.618*(H−L) + + **Camarilla**: P=(H+L+C)/3; R1=C+1.1*(H−L)/12; S1=C−1.1*(H−L)/12; + R2=C+1.1*(H−L)/6; S2=C−1.1*(H−L)/6 + """ + valid_methods = {"classic", "fibonacci", "camarilla"} + if method.lower() not in valid_methods: + raise FerroTAValueError( + f"Unknown pivot method '{method}'. Use 'classic', 'fibonacci', or 'camarilla'." + ) + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method) + except ValueError as e: + _normalize_rust_error(e) + return ( + np.asarray(pivot), + np.asarray(r1), + np.asarray(s1), + np.asarray(r2), + np.asarray(s2), + ) + + +def KELTNER_CHANNELS( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 20, + atr_period: int = 10, + multiplier: float = 2.0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Keltner Channels — EMA ± (multiplier × ATR). + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + timeperiod : int, default 20 + EMA period for the middle band. + atr_period : int, default 10 + ATR period for band width. + multiplier : float, default 2.0 + ATR multiplier. + + Returns + ------- + upper, middle, lower : numpy.ndarray + + Notes + ----- + Implemented in Rust — EMA and ATR computed inline without Python calls. + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + upper, middle, lower = _rust_keltner_channels( + h, lo, c, timeperiod, atr_period, multiplier + ) + except ValueError as e: + _normalize_rust_error(e) + return np.asarray(upper), np.asarray(middle), np.asarray(lower) + + +def HULL_MA( + close: ArrayLike, + timeperiod: int = 16, +) -> np.ndarray: + """Hull Moving Average (HMA). + + A fast-responding moving average that reduces lag. + + Parameters + ---------- + close : array-like + timeperiod : int, default 16 + + Returns + ------- + numpy.ndarray + + Notes + ----- + Formula: ``HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`` + + Implemented in Rust — all WMA computations are in-process. + """ + c = _to_f64(close) + try: + return np.asarray(_rust_hull_ma(c, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def CHANDELIER_EXIT( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 22, + multiplier: float = 3.0, +) -> tuple[np.ndarray, np.ndarray]: + """Chandelier Exit — ATR-based trailing stop levels. + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + timeperiod : int, default 22 + Lookback period for highest high / lowest low and ATR. + multiplier : float, default 3.0 + ATR multiplier. + + Returns + ------- + long_exit, short_exit : numpy.ndarray + + Notes + ----- + Implemented in Rust with O(n) monotonic deque for rolling max/min. + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier) + except ValueError as e: + _normalize_rust_error(e) + return np.asarray(long_exit), np.asarray(short_exit) + + +def VWMA( + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 20, +) -> np.ndarray: + """Volume Weighted Moving Average. + + Parameters + ---------- + close : array-like + volume : array-like + timeperiod : int, default 20 + + Returns + ------- + numpy.ndarray + + Notes + ----- + ``VWMA = sum(close * volume, n) / sum(volume, n)`` + Implemented in Rust with O(n) prefix-sum approach. + """ + c = _to_f64(close) + v = _to_f64(volume) + try: + return np.asarray(_rust_vwma(c, v, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def CHOPPINESS_INDEX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Choppiness Index — measures market choppiness (range-bound vs trending). + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + timeperiod : int, default 14 + + Returns + ------- + numpy.ndarray + Values in ``[0, 100]``. Values near 100 indicate choppy/range-bound + markets; values near 0 indicate strong trends. + + Notes + ----- + ``CI = 100 * log10(sum(ATR(1), n) / (highest_high − lowest_low)) / log10(n)`` + + Implemented in Rust with O(n) monotonic deques (no Python loop). + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "VWAP", + "SUPERTREND", + "ICHIMOKU", + "DONCHIAN", + "PIVOT_POINTS", + "KELTNER_CHANNELS", + "HULL_MA", + "CHANDELIER_EXIT", + "VWMA", + "CHOPPINESS_INDEX", +] diff --git a/ferro-ta-main/python/ferro_ta/indicators/math_ops.py b/ferro-ta-main/python/ferro_ta/indicators/math_ops.py new file mode 100644 index 0000000..a2be97b --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/math_ops.py @@ -0,0 +1,372 @@ +""" +Math Operators & Math Transforms — TA-Lib compatibility shims. + +Rolling functions (SUM, MAX, MIN, MAXINDEX, MININDEX) are implemented in Rust +using O(n) monotonic deque / prefix-sum algorithms. All other functions are +thin NumPy wrappers (element-wise operations). + +Functions +--------- +Math Operators: + ADD — Element-wise addition + SUB — Element-wise subtraction + MULT — Element-wise multiplication + DIV — Element-wise division + SUM — Rolling sum over *timeperiod* bars (Rust) + MAX — Rolling maximum over *timeperiod* bars (Rust) + MIN — Rolling minimum over *timeperiod* bars (Rust) + MAXINDEX — Index of rolling maximum over *timeperiod* bars (Rust) + MININDEX — Index of rolling minimum over *timeperiod* bars (Rust) + +Math Transforms (element-wise): + ACOS ASIN ATAN CEIL COS COSH EXP FLOOR LN LOG10 SIN SINH SQRT TAN TANH + +Rust backend +------------ +Rolling operators delegate to:: + + from ferro_ta._ferro_ta import rolling_sum, rolling_max, rolling_min, ... +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +# --------------------------------------------------------------------------- +# Import Rust rolling operators +# --------------------------------------------------------------------------- +from ferro_ta._ferro_ta import ( + rolling_max as _rust_rolling_max, +) +from ferro_ta._ferro_ta import ( + rolling_maxindex as _rust_rolling_maxindex, +) +from ferro_ta._ferro_ta import ( + rolling_min as _rust_rolling_min, +) +from ferro_ta._ferro_ta import ( + rolling_minindex as _rust_rolling_minindex, +) +from ferro_ta._ferro_ta import ( + rolling_sum as _rust_rolling_sum, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + +# --------------------------------------------------------------------------- +# Math Operators +# --------------------------------------------------------------------------- + + +def ADD(real0: ArrayLike, real1: ArrayLike) -> np.ndarray: + """Element-wise addition: real0 + real1. + + Parameters + ---------- + real0, real1 : array-like + Input arrays (same length). + + Returns + ------- + numpy.ndarray[float64] + """ + try: + return np.add(_to_f64(real0), _to_f64(real1)) + except ValueError as e: + _normalize_rust_error(e) + + +def SUB(real0: ArrayLike, real1: ArrayLike) -> np.ndarray: + """Element-wise subtraction: real0 - real1. + + Parameters + ---------- + real0, real1 : array-like + Input arrays (same length). + + Returns + ------- + numpy.ndarray[float64] + """ + try: + return np.subtract(_to_f64(real0), _to_f64(real1)) + except ValueError as e: + _normalize_rust_error(e) + + +def MULT(real0: ArrayLike, real1: ArrayLike) -> np.ndarray: + """Element-wise multiplication: real0 * real1. + + Parameters + ---------- + real0, real1 : array-like + Input arrays (same length). + + Returns + ------- + numpy.ndarray[float64] + """ + try: + return np.multiply(_to_f64(real0), _to_f64(real1)) + except ValueError as e: + _normalize_rust_error(e) + + +def DIV(real0: ArrayLike, real1: ArrayLike) -> np.ndarray: + """Element-wise division: real0 / real1. + + Parameters + ---------- + real0, real1 : array-like + Input arrays (same length). + + Returns + ------- + numpy.ndarray[float64] + """ + try: + # Suppress divide-by-zero warnings while preserving inf/NaN outputs. + with np.errstate(divide="ignore", invalid="ignore"): + return np.divide(_to_f64(real0), _to_f64(real1)) + except ValueError as e: + _normalize_rust_error(e) + + +def SUM(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Rolling sum over *timeperiod* bars. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[float64] + NaN for the first ``timeperiod - 1`` bars. + + Notes + ----- + Implemented in Rust using O(n) prefix-sum algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_sum(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def MAX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Rolling maximum over *timeperiod* bars. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[float64] + NaN for the first ``timeperiod - 1`` bars. + + Notes + ----- + Implemented in Rust using O(n) monotonic deque algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_max(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def MIN(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Rolling minimum over *timeperiod* bars. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[float64] + NaN for the first ``timeperiod - 1`` bars. + + Notes + ----- + Implemented in Rust using O(n) monotonic deque algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_min(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Index of the rolling maximum over *timeperiod* bars. + + The index is the absolute position in the input array. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[int64] + -1 for the first ``timeperiod - 1`` bars (warmup period). + + Notes + ----- + Implemented in Rust using O(n) monotonic deque algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_maxindex(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def MININDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Index of the rolling minimum over *timeperiod* bars. + + The index is the absolute position in the input array. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[int64] + -1 for the first ``timeperiod - 1`` bars (warmup period). + + Notes + ----- + Implemented in Rust using O(n) monotonic deque algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_minindex(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +# --------------------------------------------------------------------------- +# Math Transforms (element-wise) +# --------------------------------------------------------------------------- + + +def ACOS(real: ArrayLike) -> np.ndarray: + """Arc cosine (element-wise). Returns NaN outside [-1, 1].""" + with np.errstate(invalid="ignore"): + return np.arccos(_to_f64(real)) + + +def ASIN(real: ArrayLike) -> np.ndarray: + """Arc sine (element-wise). Returns NaN outside [-1, 1].""" + with np.errstate(invalid="ignore"): + return np.arcsin(_to_f64(real)) + + +def ATAN(real: ArrayLike) -> np.ndarray: + """Arc tangent (element-wise).""" + return np.arctan(_to_f64(real)) + + +def CEIL(real: ArrayLike) -> np.ndarray: + """Ceiling (element-wise).""" + return np.ceil(_to_f64(real)) + + +def COS(real: ArrayLike) -> np.ndarray: + """Cosine (element-wise).""" + return np.cos(_to_f64(real)) + + +def COSH(real: ArrayLike) -> np.ndarray: + """Hyperbolic cosine (element-wise).""" + return np.cosh(_to_f64(real)) + + +def EXP(real: ArrayLike) -> np.ndarray: + """Exponential (element-wise).""" + return np.exp(_to_f64(real)) + + +def FLOOR(real: ArrayLike) -> np.ndarray: + """Floor (element-wise).""" + return np.floor(_to_f64(real)) + + +def LN(real: ArrayLike) -> np.ndarray: + """Natural logarithm (element-wise). Returns NaN for non-positive inputs.""" + with np.errstate(divide="ignore", invalid="ignore"): + return np.log(_to_f64(real)) + + +def LOG10(real: ArrayLike) -> np.ndarray: + """Base-10 logarithm (element-wise). Returns NaN for non-positive inputs.""" + with np.errstate(divide="ignore", invalid="ignore"): + return np.log10(_to_f64(real)) + + +def SIN(real: ArrayLike) -> np.ndarray: + """Sine (element-wise).""" + return np.sin(_to_f64(real)) + + +def SINH(real: ArrayLike) -> np.ndarray: + """Hyperbolic sine (element-wise).""" + return np.sinh(_to_f64(real)) + + +def SQRT(real: ArrayLike) -> np.ndarray: + """Square root (element-wise). Returns NaN for negative inputs.""" + with np.errstate(invalid="ignore"): + return np.sqrt(_to_f64(real)) + + +def TAN(real: ArrayLike) -> np.ndarray: + """Tangent (element-wise).""" + return np.tan(_to_f64(real)) + + +def TANH(real: ArrayLike) -> np.ndarray: + """Hyperbolic tangent (element-wise).""" + return np.tanh(_to_f64(real)) + + +__all__ = [ + # Math Operators + "ADD", + "SUB", + "MULT", + "DIV", + "SUM", + "MAX", + "MIN", + "MAXINDEX", + "MININDEX", + # Math Transforms + "ACOS", + "ASIN", + "ATAN", + "CEIL", + "COS", + "COSH", + "EXP", + "FLOOR", + "LN", + "LOG10", + "SIN", + "SINH", + "SQRT", + "TAN", + "TANH", +] diff --git a/ferro-ta-main/python/ferro_ta/indicators/momentum.py b/ferro-ta-main/python/ferro_ta/indicators/momentum.py new file mode 100644 index 0000000..cd7b28b --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/momentum.py @@ -0,0 +1,908 @@ +""" +Momentum Indicators — Oscillators measuring speed and change of price movements. + +Functions +--------- +RSI — Relative Strength Index +MOM — Momentum +ROC — Rate of Change: ((price/prevPrice)-1)*100 +ROCP — Rate of Change Percentage: (price-prevPrice)/prevPrice +ROCR — Rate of Change Ratio: price/prevPrice +ROCR100 — Rate of Change Ratio 100 scale: (price/prevPrice)*100 +WILLR — Williams' %R +AROON — Aroon (returns aroon_down, aroon_up) +AROONOSC — Aroon Oscillator +CCI — Commodity Channel Index +MFI — Money Flow Index +BOP — Balance Of Power +STOCHF — Stochastic Fast +STOCH — Stochastic +STOCHRSI — Stochastic Relative Strength Index +APO — Absolute Price Oscillator +PPO — Percentage Price Oscillator +CMO — Chande Momentum Oscillator +PLUS_DM — Plus Directional Movement +MINUS_DM — Minus Directional Movement +PLUS_DI — Plus Directional Indicator +MINUS_DI — Minus Directional Indicator +DX — Directional Movement Index +ADX — Average Directional Movement Index +ADXR — Average Directional Movement Index Rating +TRIX — 1-day Rate-Of-Change of Triple Smooth EMA +ULTOSC — Ultimate Oscillator +TRANGE — True Range (also in volatility) +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + adx as _adx, +) +from ferro_ta._ferro_ta import ( + adxr as _adxr, +) +from ferro_ta._ferro_ta import ( + apo as _apo, +) +from ferro_ta._ferro_ta import ( + aroon as _aroon, +) +from ferro_ta._ferro_ta import ( + aroonosc as _aroonosc, +) +from ferro_ta._ferro_ta import ( + bop as _bop, +) +from ferro_ta._ferro_ta import ( + cci as _cci, +) +from ferro_ta._ferro_ta import ( + cmo as _cmo, +) +from ferro_ta._ferro_ta import ( + dx as _dx, +) +from ferro_ta._ferro_ta import ( + mfi as _mfi, +) +from ferro_ta._ferro_ta import ( + minus_di as _minus_di, +) +from ferro_ta._ferro_ta import ( + minus_dm as _minus_dm, +) +from ferro_ta._ferro_ta import ( + mom as _mom, +) +from ferro_ta._ferro_ta import ( + plus_di as _plus_di, +) +from ferro_ta._ferro_ta import ( + plus_dm as _plus_dm, +) +from ferro_ta._ferro_ta import ( + ppo as _ppo, +) +from ferro_ta._ferro_ta import ( + roc as _roc, +) +from ferro_ta._ferro_ta import ( + rocp as _rocp, +) +from ferro_ta._ferro_ta import ( + rocr as _rocr, +) +from ferro_ta._ferro_ta import ( + rocr100 as _rocr100, +) +from ferro_ta._ferro_ta import ( + rsi as _rsi, +) +from ferro_ta._ferro_ta import ( + stoch as _stoch, +) +from ferro_ta._ferro_ta import ( + stochf as _stochf, +) +from ferro_ta._ferro_ta import ( + stochrsi as _stochrsi, +) +from ferro_ta._ferro_ta import ( + trix as _trix, +) +from ferro_ta._ferro_ta import ( + ultosc as _ultosc, +) +from ferro_ta._ferro_ta import ( + willr as _willr, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error +from ferro_ta.indicators.volatility import TRANGE + + +def RSI(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Relative Strength Index. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of RSI values (0–100); leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _rsi(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MOM(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Momentum. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of MOM values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _mom(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ROC(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Rate of Change: ((price/prevPrice)-1)*100. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of ROC values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _roc(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ROCP(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Rate of Change Percentage: (price-prevPrice)/prevPrice. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of ROCP values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _rocp(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ROCR(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Rate of Change Ratio: price/prevPrice. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of ROCR values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _rocr(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ROCR100(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Rate of Change Ratio 100 scale: (price/prevPrice)*100. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of ROCR100 values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _rocr100(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def WILLR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Williams' %R. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of WILLR values (-100 to 0); leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _willr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def AROON( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> tuple[np.ndarray, np.ndarray]: + """Aroon. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(aroondown, aroonup)`` — two arrays of equal length. + Leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _aroon(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def AROONOSC( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Aroon Oscillator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of AROONOSC values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _aroonosc(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def CCI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Commodity Channel Index. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of CCI values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _cci(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MFI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Money Flow Index. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volume values. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of MFI values (0–100); leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _mfi( + _to_f64(high), _to_f64(low), _to_f64(close), _to_f64(volume), timeperiod + ) + except ValueError as e: + _normalize_rust_error(e) + + +def BOP( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Balance Of Power. + + Parameters + ---------- + open : array-like + Sequence of open prices. + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of BOP values (-1 to 1). + """ + try: + return _bop(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def STOCHF( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + fastd_period: int = 3, +) -> tuple[np.ndarray, np.ndarray]: + """Stochastic Fast. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + fastk_period : int, optional + %K period (default 5). + fastd_period : int, optional + %D smoothing period (default 3). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(fastk, fastd)`` — two arrays of equal length. + """ + try: + return _stochf( + _to_f64(high), _to_f64(low), _to_f64(close), fastk_period, fastd_period + ) + except ValueError as e: + _normalize_rust_error(e) + + +def STOCH( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + slowk_period: int = 3, + slowd_period: int = 3, +) -> tuple[np.ndarray, np.ndarray]: + """Stochastic. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + fastk_period : int, optional + Fast %K period (default 5). + slowk_period : int, optional + Slow %K smoothing period (default 3). + slowd_period : int, optional + Slow %D smoothing period (default 3). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(slowk, slowd)`` — two arrays of equal length. + """ + try: + return _stoch( + _to_f64(high), + _to_f64(low), + _to_f64(close), + fastk_period, + slowk_period, + slowd_period, + ) + except ValueError as e: + _normalize_rust_error(e) + + +def STOCHRSI( + close: ArrayLike, + timeperiod: int = 14, + fastk_period: int = 5, + fastd_period: int = 3, +) -> tuple[np.ndarray, np.ndarray]: + """Stochastic Relative Strength Index. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + RSI period (default 14). + fastk_period : int, optional + Stochastic %K period (default 5). + fastd_period : int, optional + Stochastic %D smoothing period (default 3). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(fastk, fastd)`` — two arrays of equal length. + """ + try: + return _stochrsi(_to_f64(close), timeperiod, fastk_period, fastd_period) + except ValueError as e: + _normalize_rust_error(e) + + +def APO( + close: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, +) -> np.ndarray: + """Absolute Price Oscillator. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastperiod : int, optional + Fast EMA period (default 12). + slowperiod : int, optional + Slow EMA period (default 26). + + Returns + ------- + numpy.ndarray + Array of APO values; leading ``slowperiod - 1`` entries are ``NaN``. + """ + try: + return _apo(_to_f64(close), fastperiod, slowperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def PPO( + close: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Percentage Price Oscillator. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastperiod : int, optional + Fast EMA period (default 12). + slowperiod : int, optional + Slow EMA period (default 26). + signalperiod : int, optional + Signal EMA period (default 9). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(ppo, signal, histogram)`` — three arrays of equal length. + """ + try: + return _ppo(_to_f64(close), fastperiod, slowperiod, signalperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def CMO(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Chande Momentum Oscillator. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of CMO values (-100 to 100); leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _cmo(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def PLUS_DM(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Plus Directional Movement. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of +DM values. + """ + try: + return _plus_dm(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MINUS_DM(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Minus Directional Movement. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of -DM values. + """ + try: + return _minus_dm(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def PLUS_DI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Plus Directional Indicator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of +DI values. + """ + try: + return _plus_di(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MINUS_DI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Minus Directional Indicator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of -DI values. + """ + try: + return _minus_di(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def DX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Directional Movement Index. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of DX values (0–100). + """ + try: + return _dx(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ADX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Average Directional Movement Index. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of ADX values (0–100). + """ + try: + return _adx(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ADXR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Average Directional Movement Index Rating. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of ADXR values (0–100). + """ + try: + return _adxr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TRIX(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """1-day Rate-Of-Change of a Triple Smooth EMA. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + EMA period (default 30). + + Returns + ------- + numpy.ndarray + Array of TRIX values. + """ + try: + return _trix(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ULTOSC( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod1: int = 7, + timeperiod2: int = 14, + timeperiod3: int = 28, +) -> np.ndarray: + """Ultimate Oscillator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod1 : int, optional + First period (default 7). + timeperiod2 : int, optional + Second period (default 14). + timeperiod3 : int, optional + Third period (default 28). + + Returns + ------- + numpy.ndarray + Array of ULTOSC values (0–100). + """ + try: + return _ultosc( + _to_f64(high), + _to_f64(low), + _to_f64(close), + timeperiod1, + timeperiod2, + timeperiod3, + ) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "RSI", + "MOM", + "ROC", + "ROCP", + "ROCR", + "ROCR100", + "WILLR", + "AROON", + "AROONOSC", + "CCI", + "MFI", + "BOP", + "STOCHF", + "STOCH", + "STOCHRSI", + "APO", + "PPO", + "CMO", + "PLUS_DM", + "MINUS_DM", + "PLUS_DI", + "MINUS_DI", + "DX", + "ADX", + "ADXR", + "TRIX", + "ULTOSC", + "TRANGE", +] diff --git a/ferro-ta-main/python/ferro_ta/indicators/overlap.py b/ferro-ta-main/python/ferro_ta/indicators/overlap.py new file mode 100644 index 0000000..1bebdb1 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/overlap.py @@ -0,0 +1,656 @@ +""" +Overlap Studies — Moving averages and bands that overlay directly on the price chart. + +Functions +--------- +SMA — Simple Moving Average +EMA — Exponential Moving Average +WMA — Weighted Moving Average +DEMA — Double Exponential Moving Average +TEMA — Triple Exponential Moving Average +TRIMA — Triangular Moving Average +KAMA — Kaufman Adaptive Moving Average +T3 — Triple Exponential Moving Average (Tillson T3) +BBANDS — Bollinger Bands +MACD — Moving Average Convergence/Divergence +MACDFIX — MACD with fixed 12/26 periods +MACDEXT — MACD with controllable MA types +SAR — Parabolic SAR +SAREXT — Parabolic SAR Extended +MA — Generic Moving Average (dispatches on matype) +MAVP — Moving Average with Variable Period +MAMA — MESA Adaptive Moving Average +MIDPOINT — MidPoint over period +MIDPRICE — MidPrice over period (High/Low) +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + bbands as _bbands, +) +from ferro_ta._ferro_ta import ( + dema as _dema, +) +from ferro_ta._ferro_ta import ( + ema as _ema, +) +from ferro_ta._ferro_ta import ( + kama as _kama, +) +from ferro_ta._ferro_ta import ( + ma as _ma, +) +from ferro_ta._ferro_ta import ( + macd as _macd, +) +from ferro_ta._ferro_ta import ( + macdext as _macdext, +) +from ferro_ta._ferro_ta import ( + macdfix as _macdfix, +) +from ferro_ta._ferro_ta import ( + mama as _mama, +) +from ferro_ta._ferro_ta import ( + mavp as _mavp, +) +from ferro_ta._ferro_ta import ( + midpoint as _midpoint, +) +from ferro_ta._ferro_ta import ( + midprice as _midprice, +) +from ferro_ta._ferro_ta import ( + sar as _sar, +) +from ferro_ta._ferro_ta import ( + sarext as _sarext, +) +from ferro_ta._ferro_ta import ( + sma as _sma, +) +from ferro_ta._ferro_ta import ( + t3 as _t3, +) +from ferro_ta._ferro_ta import ( + tema as _tema, +) +from ferro_ta._ferro_ta import ( + trima as _trima, +) +from ferro_ta._ferro_ta import ( + wma as _wma, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def SMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Simple Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of SMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _sma(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def EMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Exponential Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of EMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _ema(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def WMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Weighted Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of WMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _wma(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def DEMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Double Exponential Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of DEMA values; leading ``2 * (timeperiod - 1)`` entries are ``NaN``. + """ + try: + return _dema(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TEMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Triple Exponential Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of TEMA values; leading ``3 * (timeperiod - 1)`` entries are ``NaN``. + """ + try: + return _tema(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TRIMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Triangular Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of TRIMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _trima(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def KAMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Kaufman Adaptive Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Efficiency Ratio lookback period (default 30). + + Returns + ------- + numpy.ndarray + Array of KAMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _kama(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def T3(close: ArrayLike, timeperiod: int = 5, vfactor: float = 0.7) -> np.ndarray: + """Triple Exponential Moving Average (Tillson T3). + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 5). + vfactor : float, optional + Volume factor (default 0.7). + + Returns + ------- + numpy.ndarray + Array of T3 values. + """ + try: + return _t3(_to_f64(close), timeperiod, vfactor) + except ValueError as e: + _normalize_rust_error(e) + + +def BBANDS( + close: ArrayLike, + timeperiod: int = 5, + nbdevup: float = 2.0, + nbdevdn: float = 2.0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Bollinger Bands. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Moving average window (default 5). + nbdevup : float, optional + Number of standard deviations above the middle band (default 2.0). + nbdevdn : float, optional + Number of standard deviations below the middle band (default 2.0). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(upperband, middleband, lowerband)`` — three arrays of equal length. + Leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _bbands(_to_f64(close), timeperiod, nbdevup, nbdevdn) + except ValueError as e: + _normalize_rust_error(e) + + +def MACD( + close: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Moving Average Convergence/Divergence. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastperiod : int, optional + Fast EMA period (default 12). + slowperiod : int, optional + Slow EMA period (default 26). + signalperiod : int, optional + Signal EMA period (default 9). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(macd, signal, histogram)`` — three arrays of equal length. + Leading values that cannot be computed are ``NaN``. + """ + try: + return _macd(_to_f64(close), fastperiod, slowperiod, signalperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MACDFIX( + close: ArrayLike, + signalperiod: int = 9, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Moving Average Convergence/Divergence Fix 12/26. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + signalperiod : int, optional + Signal EMA period (default 9). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(macd, signal, histogram)`` — three arrays of equal length. + """ + try: + return _macdfix(_to_f64(close), signalperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def SAR( + high: ArrayLike, + low: ArrayLike, + acceleration: float = 0.02, + maximum: float = 0.2, +) -> np.ndarray: + """Parabolic SAR. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + acceleration : float, optional + Acceleration factor step (default 0.02). + maximum : float, optional + Maximum acceleration factor (default 0.2). + + Returns + ------- + numpy.ndarray + Array of SAR values; first entry is ``NaN``. + """ + try: + return _sar(_to_f64(high), _to_f64(low), acceleration, maximum) + except ValueError as e: + _normalize_rust_error(e) + + +def MIDPOINT(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """MidPoint over period — (max + min) / 2 of close. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of MIDPOINT values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _midpoint(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MIDPRICE(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """MidPrice over period — (highest high + lowest low) / 2. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of MIDPRICE values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _midprice(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MA(close: ArrayLike, timeperiod: int = 30, matype: int = 0) -> np.ndarray: + """Generic Moving Average. + + Dispatches to the appropriate MA implementation based on *matype*. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + matype : int, optional + Moving average type (default 0): + + * 0 = SMA (Simple) + * 1 = EMA (Exponential) + * 2 = WMA (Weighted) + * 3 = DEMA (Double EMA) + * 4 = TEMA (Triple EMA) + * 5 = TRIMA (Triangular) + * 6 = KAMA (Kaufman Adaptive) + * 7 = T3 (Tillson) + + Returns + ------- + numpy.ndarray + Array of MA values. + """ + try: + return _ma(_to_f64(close), timeperiod, matype) + except ValueError as e: + _normalize_rust_error(e) + + +def MAVP( + close: ArrayLike, + periods: ArrayLike, + minperiod: int = 2, + maxperiod: int = 30, +) -> np.ndarray: + """Moving Average with Variable Period. + + Computes a simple moving average at each bar using the period given by the + corresponding element of *periods*. Periods are clamped to + ``[minperiod, maxperiod]``. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + periods : array-like + Sequence of period values (one per bar, same length as *close*). + minperiod : int, optional + Minimum allowed period (default 2). + maxperiod : int, optional + Maximum allowed period (default 30). + + Returns + ------- + numpy.ndarray + Array of variable-period MA values. + """ + try: + return _mavp(_to_f64(close), _to_f64(periods), minperiod, maxperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MAMA( + close: ArrayLike, + fastlimit: float = 0.5, + slowlimit: float = 0.05, +) -> tuple[np.ndarray, np.ndarray]: + """MESA Adaptive Moving Average. + + Returns the MAMA and FAMA (Following Adaptive MA) lines. The adaptive + alpha is derived from the rate of phase change of the Hilbert Transform. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastlimit : float, optional + Upper bound on the adaptive smoothing factor (default 0.5). + slowlimit : float, optional + Lower bound on the adaptive smoothing factor (default 0.05). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(mama, fama)`` — two arrays; first 32 entries are ``NaN``. + """ + try: + return _mama(_to_f64(close), fastlimit, slowlimit) + except ValueError as e: + _normalize_rust_error(e) + + +def SAREXT( + high: ArrayLike, + low: ArrayLike, + startvalue: float = 0.0, + offsetonreverse: float = 0.0, + accelerationinitlong: float = 0.02, + accelerationlong: float = 0.02, + accelerationmaxlong: float = 0.2, + accelerationinitshort: float = 0.02, + accelerationshort: float = 0.02, + accelerationmaxshort: float = 0.2, +) -> np.ndarray: + """Parabolic SAR Extended. + + An extended version of the Parabolic SAR that allows independent + acceleration parameters for long and short positions, plus an optional + fixed start value and a gap-on-reverse offset. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + startvalue : float, optional + Fixed initial SAR value (0 = auto-detect, default 0.0). + offsetonreverse : float, optional + Multiplier applied to the SAR on trend reversal (default 0.0). + accelerationinitlong : float, optional + Initial acceleration factor for long positions (default 0.02). + accelerationlong : float, optional + Acceleration step for long positions (default 0.02). + accelerationmaxlong : float, optional + Maximum acceleration for long positions (default 0.2). + accelerationinitshort : float, optional + Initial acceleration factor for short positions (default 0.02). + accelerationshort : float, optional + Acceleration step for short positions (default 0.02). + accelerationmaxshort : float, optional + Maximum acceleration for short positions (default 0.2). + + Returns + ------- + numpy.ndarray + Array of SAREXT values; first entry is ``NaN``. + """ + try: + return _sarext( + _to_f64(high), + _to_f64(low), + startvalue, + offsetonreverse, + accelerationinitlong, + accelerationlong, + accelerationmaxlong, + accelerationinitshort, + accelerationshort, + accelerationmaxshort, + ) + except ValueError as e: + _normalize_rust_error(e) + + +def MACDEXT( + close: ArrayLike, + fastperiod: int = 12, + fastmatype: int = 1, + slowperiod: int = 26, + slowmatype: int = 1, + signalperiod: int = 9, + signalmatype: int = 1, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """MACD with Controllable MA Types. + + Like :func:`MACD` but allows specifying the moving average type for each + of the fast, slow, and signal lines independently. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastperiod : int, optional + Fast MA period (default 12). + fastmatype : int, optional + MA type for the fast line (default 1 = EMA). + slowperiod : int, optional + Slow MA period (default 26). + slowmatype : int, optional + MA type for the slow line (default 1 = EMA). + signalperiod : int, optional + Signal MA period (default 9). + signalmatype : int, optional + MA type for the signal line (default 1 = EMA). + + MA type codes: 0=SMA, 1=EMA, 2=WMA. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(macd, signal, histogram)`` — three arrays of equal length. + """ + try: + return _macdext( + _to_f64(close), + fastperiod, + fastmatype, + slowperiod, + slowmatype, + signalperiod, + signalmatype, + ) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "SMA", + "EMA", + "WMA", + "DEMA", + "TEMA", + "TRIMA", + "KAMA", + "T3", + "BBANDS", + "MACD", + "MACDFIX", + "MACDEXT", + "SAR", + "SAREXT", + "MA", + "MAVP", + "MAMA", + "MIDPOINT", + "MIDPRICE", +] diff --git a/ferro-ta-main/python/ferro_ta/indicators/pattern.py b/ferro-ta-main/python/ferro_ta/indicators/pattern.py new file mode 100644 index 0000000..a398b0c --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/pattern.py @@ -0,0 +1,1959 @@ +""" +Pattern Recognition — Candlestick pattern detection. + +All functions return an integer array where: + 100 = bullish signal + -100 = bearish signal + 0 = no pattern detected + +Functions +--------- +CDL2CROWS — Two Crows (bearish) +CDL3BLACKCROWS — Three Black Crows (bearish) +CDL3WHITESOLDIERS — Three White Soldiers (bullish) +CDL3INSIDE — Three Inside Up/Down +CDL3OUTSIDE — Three Outside Up/Down +CDLDOJI — Doji +CDLDOJISTAR — Doji Star +CDLENGULFING — Engulfing Pattern +CDLHAMMER — Hammer (bullish) +CDLHARAMI — Harami Pattern +CDLHARAMICROSS — Harami Cross Pattern +CDLMARUBOZU — Marubozu +CDLMORNINGSTAR — Morning Star (bullish, 3-candle) +CDLMORNINGDOJISTAR — Morning Doji Star (bullish, 3-candle) +CDLEVENINGSTAR — Evening Star (bearish, 3-candle) +CDLEVENINGDOJISTAR — Evening Doji Star (bearish, 3-candle) +CDLSHOOTINGSTAR — Shooting Star (bearish) +CDLSPINNINGTOP — Spinning Top +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + cdl2crows as _cdl2crows, +) +from ferro_ta._ferro_ta import ( + cdl3blackcrows as _cdl3blackcrows, +) +from ferro_ta._ferro_ta import ( + cdl3inside as _cdl3inside, +) +from ferro_ta._ferro_ta import ( + cdl3linestrike as _cdl3linestrike, +) +from ferro_ta._ferro_ta import ( + cdl3outside as _cdl3outside, +) +from ferro_ta._ferro_ta import ( + cdl3starsinsouth as _cdl3starsinsouth, +) +from ferro_ta._ferro_ta import ( + cdl3whitesoldiers as _cdl3whitesoldiers, +) +from ferro_ta._ferro_ta import ( + cdlabandonedbaby as _cdlabandonedbaby, +) +from ferro_ta._ferro_ta import ( + cdladvanceblock as _cdladvanceblock, +) +from ferro_ta._ferro_ta import ( + cdlbelthold as _cdlbelthold, +) +from ferro_ta._ferro_ta import ( + cdlbreakaway as _cdlbreakaway, +) +from ferro_ta._ferro_ta import ( + cdlclosingmarubozu as _cdlclosingmarubozu, +) +from ferro_ta._ferro_ta import ( + cdlconcealbabyswall as _cdlconcealbabyswall, +) +from ferro_ta._ferro_ta import ( + cdlcounterattack as _cdlcounterattack, +) +from ferro_ta._ferro_ta import ( + cdldarkcloudcover as _cdldarkcloudcover, +) +from ferro_ta._ferro_ta import ( + cdldoji as _cdldoji, +) +from ferro_ta._ferro_ta import ( + cdldojistar as _cdldojistar, +) +from ferro_ta._ferro_ta import ( + cdldragonflydoji as _cdldragonflydoji, +) +from ferro_ta._ferro_ta import ( + cdlengulfing as _cdlengulfing, +) +from ferro_ta._ferro_ta import ( + cdleveningdojistar as _cdleveningdojistar, +) +from ferro_ta._ferro_ta import ( + cdleveningstar as _cdleveningstar, +) +from ferro_ta._ferro_ta import ( + cdlgapsidesidewhite as _cdlgapsidesidewhite, +) +from ferro_ta._ferro_ta import ( + cdlgravestonedoji as _cdlgravestonedoji, +) +from ferro_ta._ferro_ta import ( + cdlhammer as _cdlhammer, +) +from ferro_ta._ferro_ta import ( + cdlhangingman as _cdlhangingman, +) +from ferro_ta._ferro_ta import ( + cdlharami as _cdlharami, +) +from ferro_ta._ferro_ta import ( + cdlharamicross as _cdlharamicross, +) +from ferro_ta._ferro_ta import ( + cdlhighwave as _cdlhighwave, +) +from ferro_ta._ferro_ta import ( + cdlhikkake as _cdlhikkake, +) +from ferro_ta._ferro_ta import ( + cdlhikkakemod as _cdlhikkakemod, +) +from ferro_ta._ferro_ta import ( + cdlhomingpigeon as _cdlhomingpigeon, +) +from ferro_ta._ferro_ta import ( + cdlidentical3crows as _cdlidentical3crows, +) +from ferro_ta._ferro_ta import ( + cdlinneck as _cdlinneck, +) +from ferro_ta._ferro_ta import ( + cdlinvertedhammer as _cdlinvertedhammer, +) +from ferro_ta._ferro_ta import ( + cdlkicking as _cdlkicking, +) +from ferro_ta._ferro_ta import ( + cdlkickingbylength as _cdlkickingbylength, +) +from ferro_ta._ferro_ta import ( + cdlladderbottom as _cdlladderbottom, +) +from ferro_ta._ferro_ta import ( + cdllongleggeddoji as _cdllongleggeddoji, +) +from ferro_ta._ferro_ta import ( + cdllongline as _cdllongline, +) +from ferro_ta._ferro_ta import ( + cdlmarubozu as _cdlmarubozu, +) +from ferro_ta._ferro_ta import ( + cdlmatchinglow as _cdlmatchinglow, +) +from ferro_ta._ferro_ta import ( + cdlmathold as _cdlmathold, +) +from ferro_ta._ferro_ta import ( + cdlmorningdojistar as _cdlmorningdojistar, +) +from ferro_ta._ferro_ta import ( + cdlmorningstar as _cdlmorningstar, +) +from ferro_ta._ferro_ta import ( + cdlonneck as _cdlonneck, +) +from ferro_ta._ferro_ta import ( + cdlpiercing as _cdlpiercing, +) +from ferro_ta._ferro_ta import ( + cdlrickshawman as _cdlrickshawman, +) +from ferro_ta._ferro_ta import ( + cdlrisefall3methods as _cdlrisefall3methods, +) +from ferro_ta._ferro_ta import ( + cdlseparatinglines as _cdlseparatinglines, +) +from ferro_ta._ferro_ta import ( + cdlshootingstar as _cdlshootingstar, +) +from ferro_ta._ferro_ta import ( + cdlshortline as _cdlshortline, +) +from ferro_ta._ferro_ta import ( + cdlspinningtop as _cdlspinningtop, +) +from ferro_ta._ferro_ta import ( + cdlstalledpattern as _cdlstalledpattern, +) +from ferro_ta._ferro_ta import ( + cdlsticksandwich as _cdlsticksandwich, +) +from ferro_ta._ferro_ta import ( + cdltakuri as _cdltakuri, +) +from ferro_ta._ferro_ta import ( + cdltasukigap as _cdltasukigap, +) +from ferro_ta._ferro_ta import ( + cdlthrusting as _cdlthrusting, +) +from ferro_ta._ferro_ta import ( + cdltristar as _cdltristar, +) +from ferro_ta._ferro_ta import ( + cdlunique3river as _cdlunique3river, +) +from ferro_ta._ferro_ta import ( + cdlupsidegap2crows as _cdlupsidegap2crows, +) +from ferro_ta._ferro_ta import ( + cdlxsidegap3methods as _cdlxsidegap3methods, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import FerroTAInputError, _normalize_rust_error + + +def _validate_ohlc_lengths(o, h, lo, c) -> None: + if not (len(o) == len(h) == len(lo) == len(c)): + raise FerroTAInputError( + f"All OHLC arrays must have the same length " + f"(open={len(o)}, high={len(h)}, low={len(lo)}, close={len(c)}).", + ) + + +def CDL2CROWS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Two Crows — bearish 3-candle reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl2crows(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLDOJI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Doji — open ≈ close, reflecting market indecision. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdldoji(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLENGULFING( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Engulfing Pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlengulfing(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHAMMER( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Hammer — small body at top, long lower shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhammer(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSHOOTINGSTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Shooting Star — small body at bottom, long upper shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlshootingstar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMORNINGSTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Morning Star — 3-candle bullish reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmorningstar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLEVENINGSTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Evening Star — 3-candle bearish reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdleveningstar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMARUBOZU( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Marubozu — full body candle with no or minimal shadows. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmarubozu(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSPINNINGTOP( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Spinning Top — small body with shadows longer than the body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlspinningtop( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3BLACKCROWS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three Black Crows — bearish 3-candle reversal. + + Three consecutive long bearish candles, each opening within the prior body + and closing near its low. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3blackcrows( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3WHITESOLDIERS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three White Soldiers — bullish 3-candle reversal. + + Three consecutive long bullish candles, each opening within the prior body + and closing near its high. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3whitesoldiers( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3INSIDE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three Inside Up/Down — harami followed by confirmation candle. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish Three Inside Up), -100 (bearish Three Inside Down), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3inside(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3OUTSIDE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three Outside Up/Down — engulfing followed by confirmation candle. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish Three Outside Up), -100 (bearish Three Outside Down), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3outside(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLDOJISTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Doji Star — doji that gaps away from the prior large candle. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdldojistar(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMORNINGDOJISTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Morning Doji Star — 3-candle bullish reversal with doji star. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmorningdojistar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLEVENINGDOJISTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Evening Doji Star — 3-candle bearish reversal with doji star. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdleveningdojistar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHARAMI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Harami Pattern — small candle inside the prior large candle's body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlharami(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHARAMICROSS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Harami Cross — doji inside the prior large candle's body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlharamicross( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3LINESTRIKE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three-Line Strike — 4-candle reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3linestrike( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3STARSINSOUTH( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three Stars In The South — 3-candle bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3starsinsouth( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLABANDONEDBABY( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Abandoned Baby — 3-candle reversal with gapping doji in the middle. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlabandonedbaby( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLADVANCEBLOCK( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Advance Block — 3 bullish candles with weakening momentum, bearish warning. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdladvanceblock( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLBELTHOLD( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Belt-hold — single candle opening at extreme with long body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlbelthold(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLBREAKAWAY( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Breakaway — 5-candle reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlbreakaway(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLCLOSINGMARUBOZU( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Closing Marubozu — candle with no shadow on the closing side. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlclosingmarubozu( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLCONCEALBABYSWALL( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Concealing Baby Swallow — 4-candle bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlconcealbabyswall( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLCOUNTERATTACK( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Counterattack Lines — 2-candle pattern with opposite candles closing at same price. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlcounterattack( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLDARKCLOUDCOVER( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Dark Cloud Cover — 2-candle bearish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdldarkcloudcover( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLDRAGONFLYDOJI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Dragonfly Doji — doji with long lower shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdldragonflydoji( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLGAPSIDESIDEWHITE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Up/Down-Gap Side-by-Side White Lines — 3-candle continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (upside gap), -100 (downside gap), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlgapsidesidewhite( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLGRAVESTONEDOJI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Gravestone Doji — doji with long upper shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlgravestonedoji( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHANGINGMAN( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Hanging Man — same shape as hammer but bearish warning. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhangingman( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHIGHWAVE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """High-Wave Candle — small body with very long upper and lower shadows. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhighwave(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHIKKAKE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Hikkake Pattern — inside bar followed by false breakout then reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhikkake(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHIKKAKEMOD( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Modified Hikkake Pattern — hikkake with delayed confirmation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhikkakemod( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHOMINGPIGEON( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Homing Pigeon — 2 bearish candles, second inside the first body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhomingpigeon( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLIDENTICAL3CROWS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Identical Three Crows — 3 bearish candles each opening at prior close. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlidentical3crows( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLINNECK( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """In-Neck Pattern — bearish then bullish closing near prior close, bearish continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlinneck(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLINVERTEDHAMMER( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Inverted Hammer — small body at bottom, long upper shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlinvertedhammer( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLKICKING( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Kicking — two opposite marubozu candles with a gap. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlkicking(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLKICKINGBYLENGTH( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Kicking by the Longer Marubozu — direction determined by longer marubozu. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlkickingbylength( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLLADDERBOTTOM( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Ladder Bottom — 5-candle bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlladderbottom( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLLONGLEGGEDDOJI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Long Legged Doji — doji with long upper and lower shadows. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdllongleggeddoji( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLLONGLINE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Long Line Candle — long body candle (body >= 70% of range). + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdllongline(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMATCHINGLOW( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Matching Low — 2 bearish candles with equal closes, bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmatchinglow( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMATHOLD( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Mat Hold — 5-candle bullish continuation pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmathold(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLONNECK( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """On-Neck Pattern — bearish then bullish reaching only prior low, bearish continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlonneck(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLPIERCING( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Piercing Pattern — bearish then bullish piercing past midpoint, bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlpiercing(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLRICKSHAWMAN( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Rickshaw Man — doji with long shadows and body near center. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlrickshawman( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLRISEFALL3METHODS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Rising/Falling Three Methods — 5-candle continuation pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlrisefall3methods( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSEPARATINGLINES( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Separating Lines — 2-candle continuation with same open, opposite direction. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlseparatinglines( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSHORTLINE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Short Line Candle — small body (body <= 30% of range). + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlshortline(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSTALLEDPATTERN( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Stalled Pattern — 3 bullish candles with stalling on the third, bearish warning. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlstalledpattern( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSTICKSANDWICH( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Stick Sandwich — 2 bearish candles surrounding a bullish, same close. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlsticksandwich( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLTAKURI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Takuri — Dragonfly Doji with very long lower shadow (>= 3x body). + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdltakuri(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLTASUKIGAP( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Tasuki Gap — 3-candle gap continuation with partial fill. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdltasukigap(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLTHRUSTING( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Thrusting Pattern — bearish then bullish reaching below midpoint, bearish continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlthrusting(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLTRISTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Tristar Pattern — 3 dojis with reversal implication. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdltristar(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLUNIQUE3RIVER( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Unique 3 River — 3-candle bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlunique3river( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLUPSIDEGAP2CROWS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Upside Gap Two Crows — 3-candle bearish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlupsidegap2crows( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLXSIDEGAP3METHODS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Upside/Downside Gap Three Methods — 3-candle gap fill continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlxsidegap3methods( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "CDL2CROWS", + "CDL3BLACKCROWS", + "CDL3INSIDE", + "CDL3LINESTRIKE", + "CDL3OUTSIDE", + "CDL3STARSINSOUTH", + "CDL3WHITESOLDIERS", + "CDLABANDONEDBABY", + "CDLADVANCEBLOCK", + "CDLBELTHOLD", + "CDLBREAKAWAY", + "CDLCLOSINGMARUBOZU", + "CDLCONCEALBABYSWALL", + "CDLCOUNTERATTACK", + "CDLDARKCLOUDCOVER", + "CDLDOJI", + "CDLDOJISTAR", + "CDLDRAGONFLYDOJI", + "CDLENGULFING", + "CDLEVENINGDOJISTAR", + "CDLEVENINGSTAR", + "CDLGAPSIDESIDEWHITE", + "CDLGRAVESTONEDOJI", + "CDLHAMMER", + "CDLHANGINGMAN", + "CDLHARAMI", + "CDLHARAMICROSS", + "CDLHIGHWAVE", + "CDLHIKKAKE", + "CDLHIKKAKEMOD", + "CDLHOMINGPIGEON", + "CDLIDENTICAL3CROWS", + "CDLINNECK", + "CDLINVERTEDHAMMER", + "CDLKICKING", + "CDLKICKINGBYLENGTH", + "CDLLADDERBOTTOM", + "CDLLONGLEGGEDDOJI", + "CDLLONGLINE", + "CDLMARUBOZU", + "CDLMATCHINGLOW", + "CDLMATHOLD", + "CDLMORNINGDOJISTAR", + "CDLMORNINGSTAR", + "CDLONNECK", + "CDLPIERCING", + "CDLRICKSHAWMAN", + "CDLRISEFALL3METHODS", + "CDLSEPARATINGLINES", + "CDLSHOOTINGSTAR", + "CDLSHORTLINE", + "CDLSPINNINGTOP", + "CDLSTALLEDPATTERN", + "CDLSTICKSANDWICH", + "CDLTAKURI", + "CDLTASUKIGAP", + "CDLTHRUSTING", + "CDLTRISTAR", + "CDLUNIQUE3RIVER", + "CDLUPSIDEGAP2CROWS", + "CDLXSIDEGAP3METHODS", +] diff --git a/ferro-ta-main/python/ferro_ta/indicators/price_transform.py b/ferro-ta-main/python/ferro_ta/indicators/price_transform.py new file mode 100644 index 0000000..6d2c8b9 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/price_transform.py @@ -0,0 +1,130 @@ +""" +Price Transformations — Helper functions to synthesize OHLC arrays into single arrays. + +Functions +--------- +AVGPRICE — Average Price: (Open + High + Low + Close) / 4 +MEDPRICE — Median Price: (High + Low) / 2 +TYPPRICE — Typical Price: (High + Low + Close) / 3 +WCLPRICE — Weighted Close Price: (High + Low + Close * 2) / 4 +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + avgprice as _avgprice, +) +from ferro_ta._ferro_ta import ( + medprice as _medprice, +) +from ferro_ta._ferro_ta import ( + typprice as _typprice, +) +from ferro_ta._ferro_ta import ( + wclprice as _wclprice, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def AVGPRICE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Average Price: (Open + High + Low + Close) / 4. + + Parameters + ---------- + open : array-like + Sequence of open prices. + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of AVGPRICE values. + """ + try: + return _avgprice(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def MEDPRICE(high: ArrayLike, low: ArrayLike) -> np.ndarray: + """Median Price: (High + Low) / 2. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + + Returns + ------- + numpy.ndarray + Array of MEDPRICE values. + """ + try: + return _medprice(_to_f64(high), _to_f64(low)) + except ValueError as e: + _normalize_rust_error(e) + + +def TYPPRICE(high: ArrayLike, low: ArrayLike, close: ArrayLike) -> np.ndarray: + """Typical Price: (High + Low + Close) / 3. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of TYPPRICE values. + """ + try: + return _typprice(_to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def WCLPRICE(high: ArrayLike, low: ArrayLike, close: ArrayLike) -> np.ndarray: + """Weighted Close Price: (High + Low + Close * 2) / 4. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of WCLPRICE values. + """ + try: + return _wclprice(_to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = ["AVGPRICE", "MEDPRICE", "TYPPRICE", "WCLPRICE"] diff --git a/ferro-ta-main/python/ferro_ta/indicators/statistic.py b/ferro-ta-main/python/ferro_ta/indicators/statistic.py new file mode 100644 index 0000000..a441d90 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/statistic.py @@ -0,0 +1,369 @@ +""" +Statistic Functions — Standard statistical math applied to rolling windows of price data. + +Functions +--------- +STDDEV — Standard Deviation +VAR — Variance +LINEARREG — Linear Regression +LINEARREG_SLOPE — Linear Regression Slope +LINEARREG_INTERCEPT — Linear Regression Intercept +LINEARREG_ANGLE — Linear Regression Angle (degrees) +TSF — Time Series Forecast +BETA — Beta +CORREL — Pearson's Correlation Coefficient (r) +DTW — Dynamic Time Warping (distance + warping path) +DTW_DISTANCE — Dynamic Time Warping distance only (faster) +BATCH_DTW — Batch DTW: N series vs 1 reference, in parallel +""" + +from __future__ import annotations + +from typing import Optional + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + batch_dtw as _batch_dtw, +) +from ferro_ta._ferro_ta import ( + beta as _beta, +) +from ferro_ta._ferro_ta import ( + correl as _correl, +) +from ferro_ta._ferro_ta import ( + dtw as _dtw, +) +from ferro_ta._ferro_ta import ( + dtw_distance as _dtw_distance, +) +from ferro_ta._ferro_ta import ( + linearreg as _linearreg, +) +from ferro_ta._ferro_ta import ( + linearreg_angle as _linearreg_angle, +) +from ferro_ta._ferro_ta import ( + linearreg_intercept as _linearreg_intercept, +) +from ferro_ta._ferro_ta import ( + linearreg_slope as _linearreg_slope, +) +from ferro_ta._ferro_ta import ( + stddev as _stddev, +) +from ferro_ta._ferro_ta import ( + tsf as _tsf, +) +from ferro_ta._ferro_ta import ( + var as _var, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def STDDEV(close: ArrayLike, timeperiod: int = 5, nbdev: float = 1.0) -> np.ndarray: + """Standard Deviation. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Rolling window size (default 5). + nbdev : float, optional + Number of standard deviations (default 1.0). + + Returns + ------- + numpy.ndarray + Array of STDDEV values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _stddev(_to_f64(close), timeperiod, nbdev) + except ValueError as e: + _normalize_rust_error(e) + + +def VAR(close: ArrayLike, timeperiod: int = 5, nbdev: float = 1.0) -> np.ndarray: + """Variance. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Rolling window size (default 5). + nbdev : float, optional + Number of deviations (default 1.0). + + Returns + ------- + numpy.ndarray + Array of VAR values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _var(_to_f64(close), timeperiod, nbdev) + except ValueError as e: + _normalize_rust_error(e) + + +def LINEARREG(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Linear Regression. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of linear regression end-point values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _linearreg(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def LINEARREG_SLOPE(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Linear Regression Slope. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of slope values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _linearreg_slope(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def LINEARREG_INTERCEPT(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Linear Regression Intercept. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of intercept values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _linearreg_intercept(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def LINEARREG_ANGLE(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Linear Regression Angle (in degrees). + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of angle values in degrees; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _linearreg_angle(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TSF(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Time Series Forecast — linear regression extrapolated one period ahead. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of TSF values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _tsf(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def BETA(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 5) -> np.ndarray: + """Beta — regression slope of real0 relative to real1. + + Parameters + ---------- + real0 : array-like + Sequence of prices for asset 0 (dependent variable). + real1 : array-like + Sequence of prices for asset 1 (independent variable). + timeperiod : int, optional + Rolling window (default 5). + + Returns + ------- + numpy.ndarray + Array of BETA values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _beta(_to_f64(real0), _to_f64(real1), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Pearson's Correlation Coefficient (r). + + Parameters + ---------- + real0 : array-like + First data series. + real1 : array-like + Second data series. + timeperiod : int, optional + Rolling window (default 30). + + Returns + ------- + numpy.ndarray + Array of CORREL values (-1 to 1); leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _correl(_to_f64(real0), _to_f64(real1), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def DTW( + series1: ArrayLike, + series2: ArrayLike, + window: Optional[int] = None, +) -> tuple[float, np.ndarray]: + """Dynamic Time Warping — distance and optimal warping path. + + Parameters + ---------- + series1 : array-like + First time series. + series2 : array-like + Second time series (may differ in length from series1). + window : int, optional + Sakoe-Chiba band width. ``None`` (default) = unconstrained. + + Returns + ------- + distance : float + DTW distance (accumulated Euclidean cost along the optimal path). + path : numpy.ndarray, shape (N, 2) + Warping path as ``(i, j)`` index pairs from ``(0, 0)`` to + ``(len(series1)-1, len(series2)-1)``. + """ + try: + return _dtw(_to_f64(series1), _to_f64(series2), window) + except ValueError as e: + _normalize_rust_error(e) + + +def DTW_DISTANCE( + series1: ArrayLike, + series2: ArrayLike, + window: Optional[int] = None, +) -> float: + """Dynamic Time Warping distance only (faster — no path reconstruction). + + Parameters + ---------- + series1 : array-like + First time series. + series2 : array-like + Second time series (may differ in length from series1). + window : int, optional + Sakoe-Chiba band width. ``None`` (default) = unconstrained. + + Returns + ------- + float + DTW distance (accumulated Euclidean cost along the optimal path). + """ + try: + return _dtw_distance(_to_f64(series1), _to_f64(series2), window) + except ValueError as e: + _normalize_rust_error(e) + + +def BATCH_DTW( + matrix: ArrayLike, + reference: ArrayLike, + window: Optional[int] = None, +) -> np.ndarray: + """Batch Dynamic Time Warping — N series vs 1 reference, computed in parallel. + + Parameters + ---------- + matrix : array-like, shape (N, L) + N time series of length L. Each row is compared against ``reference``. + reference : array-like, shape (L,) + The reference series. + window : int, optional + Sakoe-Chiba band width. ``None`` (default) = unconstrained. + + Returns + ------- + numpy.ndarray, shape (N,) + DTW distance from each row of ``matrix`` to ``reference``. + """ + try: + mat = np.ascontiguousarray(matrix, dtype=np.float64) + if mat.ndim != 2: + from ferro_ta.core.exceptions import FerroTAInputError + + raise FerroTAInputError( + f"matrix must be a 2-D array, got {mat.ndim}-D.", + suggestion="Pass a 2-D NumPy array of shape (N, L).", + ) + return _batch_dtw(mat, _to_f64(reference), window) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "STDDEV", + "VAR", + "LINEARREG", + "LINEARREG_SLOPE", + "LINEARREG_INTERCEPT", + "LINEARREG_ANGLE", + "TSF", + "BETA", + "CORREL", + "DTW", + "DTW_DISTANCE", + "BATCH_DTW", +] diff --git a/ferro-ta-main/python/ferro_ta/indicators/volatility.py b/ferro-ta-main/python/ferro_ta/indicators/volatility.py new file mode 100644 index 0000000..d90815a --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/volatility.py @@ -0,0 +1,116 @@ +""" +Volatility Indicators — Measure the magnitude of price fluctuations. + +Functions +--------- +ATR — Average True Range +NATR — Normalized Average True Range +TRANGE — True Range +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + atr as _atr, +) +from ferro_ta._ferro_ta import ( + natr as _natr, +) +from ferro_ta._ferro_ta import ( + trange as _trange, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def ATR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Average True Range. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of ATR values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _atr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def NATR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Normalized Average True Range. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of NATR values (percentage); leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _natr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TRANGE( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """True Range. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of True Range values. + """ + try: + return _trange(_to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = ["ATR", "NATR", "TRANGE"] diff --git a/ferro-ta-main/python/ferro_ta/indicators/volume.py b/ferro-ta-main/python/ferro_ta/indicators/volume.py new file mode 100644 index 0000000..1c7bab0 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/indicators/volume.py @@ -0,0 +1,123 @@ +""" +Volume Indicators — Require volume data to measure buying and selling pressure. + +Functions +--------- +AD — Chaikin A/D Line +ADOSC — Chaikin A/D Oscillator +OBV — On Balance Volume +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + ad as _ad, +) +from ferro_ta._ferro_ta import ( + adosc as _adosc, +) +from ferro_ta._ferro_ta import ( + obv as _obv, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def AD( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, +) -> np.ndarray: + """Chaikin A/D Line. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volume values. + + Returns + ------- + numpy.ndarray + Cumulative A/D Line values. + """ + try: + return _ad(_to_f64(high), _to_f64(low), _to_f64(close), _to_f64(volume)) + except ValueError as e: + _normalize_rust_error(e) + + +def ADOSC( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + fastperiod: int = 3, + slowperiod: int = 10, +) -> np.ndarray: + """Chaikin A/D Oscillator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volume values. + fastperiod : int, optional + Fast EMA period (default 3). + slowperiod : int, optional + Slow EMA period (default 10). + + Returns + ------- + numpy.ndarray + Array of ADOSC values; leading ``slowperiod - 1`` entries are ``NaN``. + """ + try: + return _adosc( + _to_f64(high), + _to_f64(low), + _to_f64(close), + _to_f64(volume), + fastperiod, + slowperiod, + ) + except ValueError as e: + _normalize_rust_error(e) + + +def OBV(close: ArrayLike, volume: ArrayLike) -> np.ndarray: + """On Balance Volume. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volume values. + + Returns + ------- + numpy.ndarray + Cumulative OBV values. + """ + try: + return _obv(_to_f64(close), _to_f64(volume)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = ["AD", "ADOSC", "OBV"] diff --git a/ferro-ta-main/python/ferro_ta/logging_utils.py b/ferro-ta-main/python/ferro_ta/logging_utils.py new file mode 100644 index 0000000..cb42960 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/logging_utils.py @@ -0,0 +1,8 @@ +"""Backward-compat stub — moved to ``ferro_ta.core.logging_utils``.""" + +from ferro_ta.core.logging_utils import * # noqa: F401, F403 + +try: + from ferro_ta.core.logging_utils import __all__ # noqa: F401 +except ImportError: + pass diff --git a/ferro-ta-main/python/ferro_ta/mcp/__init__.py b/ferro-ta-main/python/ferro_ta/mcp/__init__.py new file mode 100644 index 0000000..3aee4b3 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/mcp/__init__.py @@ -0,0 +1,1348 @@ +"""Optional MCP server exposing the public ferro-ta API.""" + +from __future__ import annotations + +import dataclasses +import enum +import importlib +import inspect +import json +import re +from collections.abc import Callable, Mapping +from datetime import date, datetime, time +from functools import lru_cache +from itertools import count +from typing import Any, cast, get_args, get_origin, get_type_hints + +import numpy as np + +import ferro_ta +from ferro_ta.tools import compute_indicator, run_backtest +from ferro_ta.tools.api_info import methods as api_methods + +__all__ = ["create_server", "run_server", "handle_list_tools", "handle_call_tool"] + +_MCP_INSTALL_HINT = ( + "The ferro-ta MCP server requires the optional 'mcp' dependency. " + 'Install it with `pip install "ferro-ta[mcp]"` or `uv sync --extra mcp`.' +) + +_REFERENCE_HELP = ( + "Use {'instance_id': ''} for stored objects or " + "{'callable': ''} for public callables." +) + +_JSON_ANY_TYPE: list[str] = [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string", +] + +_NO_DEFAULT = object() +_INSTANCE_STORE: dict[str, Any] = {} +_INSTANCE_META: dict[str, dict[str, Any]] = {} +_INSTANCE_COUNTER = count(1) + + +@dataclasses.dataclass(frozen=True) +class _ToolSpec: + """Resolved metadata and dispatcher for a single MCP tool.""" + + name: str + description: str + input_schema: dict[str, Any] + wrapper_signature: inspect.Signature + invoke: Callable[[dict[str, Any]], Any] + + +def _import_object(module_name: str, name: str) -> Any: + """Import *name* from *module_name*.""" + module = importlib.import_module(module_name) + return getattr(module, name) + + +@lru_cache(maxsize=1) +def _discover_public_callables() -> tuple[list[dict[str, str]], dict[str, Any]]: + """Return canonical public callable metadata and lookup targets.""" + entries = api_methods() + top_level = sorted( + [item for item in entries if item["category"] == "top_level"], + key=lambda item: item["name"], + ) + top_level_names = {item["name"] for item in top_level} + extras = sorted( + [ + item + for item in entries + if item["category"] != "top_level" and item["name"] not in top_level_names + ], + key=lambda item: item["name"], + ) + + public_entries: list[dict[str, str]] = [] + targets: dict[str, Any] = {} + for item in [*top_level, *extras]: + name = item["name"] + if name in targets: + continue + targets[name] = _import_object(item["module"], name) + public_entries.append(item) + + return public_entries, targets + + +def _slugify(value: str) -> str: + """Convert *value* into a short identifier fragment.""" + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or "object" + + +def _instance_ref(value: Any, *, source_tool: str) -> dict[str, Any]: + """Store *value* and return a serialisable reference payload.""" + identifier = f"{_slugify(type(value).__name__)}-{next(_INSTANCE_COUNTER):04d}" + _INSTANCE_STORE[identifier] = value + payload = { + "instance_id": identifier, + "type": f"{type(value).__module__}.{type(value).__name__}", + "repr": repr(value), + "callable": callable(value), + "source_tool": source_tool, + } + snapshot = _object_snapshot(value) + if snapshot is not None: + payload["snapshot"] = snapshot + _INSTANCE_META[identifier] = payload + return payload + + +def _get_instance(identifier: str) -> Any: + """Return the stored instance for *identifier* or raise a clear error.""" + try: + return _INSTANCE_STORE[identifier] + except KeyError as exc: + raise KeyError(f"Unknown instance_id: {identifier!r}") from exc + + +def _is_instance_ref(value: Any) -> bool: + """Return whether *value* is a stored-object reference payload.""" + return isinstance(value, dict) and set(value) == {"instance_id"} + + +def _is_callable_ref(value: Any) -> bool: + """Return whether *value* is a public-callable reference payload.""" + return isinstance(value, dict) and set(value) == {"callable"} + + +def _public_method_summaries(value: Any) -> list[dict[str, str]]: + """Return public callable methods for *value*.""" + result: list[dict[str, str]] = [] + for method_name, member in inspect.getmembers(value): + if method_name.startswith("_") or not callable(member): + continue + try: + signature = str(inspect.signature(member)) + except (TypeError, ValueError): + signature = "()" + result.append({"name": method_name, "signature": signature}) + return result + + +def _object_snapshot(value: Any) -> Any: + """Return a serialisable snapshot for common non-primitive objects.""" + if isinstance(value, enum.Enum): + return value.value + + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return _normalise_json(dataclasses.asdict(value), store_objects=False) + + dynamic_value = cast(Any, value) + + if hasattr(dynamic_value, "to_dict") and callable(dynamic_value.to_dict): + try: + return _normalise_json(dynamic_value.to_dict(), store_objects=False) + except TypeError: + if dynamic_value.__class__.__module__.startswith("pandas"): + return _normalise_json( + dynamic_value.to_dict(orient="list"), store_objects=False + ) + if dynamic_value.__class__.__module__.startswith("polars"): + return _normalise_json( + dynamic_value.to_dict(as_series=False), store_objects=False + ) + except Exception: + return None + + if hasattr(dynamic_value, "__dict__"): + fields = { + key: val + for key, val in vars(dynamic_value).items() + if not key.startswith("_") and not callable(val) + } + if fields: + return _normalise_json(fields, store_objects=False) + + slots = getattr(type(dynamic_value), "__slots__", ()) + if slots: + fields = {} + for slot in slots: + if slot.startswith("_") or not hasattr(dynamic_value, slot): + continue + slot_value = getattr(dynamic_value, slot) + if callable(slot_value): + continue + fields[slot] = slot_value + if fields: + return _normalise_json(fields, store_objects=False) + + return None + + +def _normalise_json(value: Any, *, store_objects: bool = True) -> Any: + """Convert Python and numpy-rich values into JSON-safe values.""" + if value is None or isinstance(value, (str, bool)): + return value + + if isinstance(value, (int, np.integer)): + return int(value) + + if isinstance(value, (float, np.floating)): + return None if np.isnan(value) else float(value) + + if isinstance(value, (date, datetime, time)): + return value.isoformat() + + if isinstance(value, enum.Enum): + return _normalise_json(value.value, store_objects=store_objects) + + if isinstance(value, np.ndarray): + return [ + _normalise_json(item, store_objects=store_objects) + for item in value.tolist() + ] + + if isinstance(value, np.generic): + return _normalise_json(value.item(), store_objects=store_objects) + + if isinstance(value, Mapping): + return { + str(key): _normalise_json(item, store_objects=store_objects) + for key, item in value.items() + } + + if isinstance(value, (list, tuple, set, frozenset)): + return [_normalise_json(item, store_objects=store_objects) for item in value] + + if hasattr(value, "tolist") and callable(value.tolist): + try: + return _normalise_json(value.tolist(), store_objects=store_objects) + except Exception: + pass + + snapshot = _object_snapshot(value) + if snapshot is not None: + return snapshot + + if store_objects: + return _instance_ref(value, source_tool="return_value") + + return repr(value) + + +def _json_result( + payload: Any, + *, + structured_key: str | None = None, +) -> dict[str, Any]: + """Wrap *payload* in the helper response shape.""" + structured: dict[str, Any] | None = None + if isinstance(payload, dict): + structured = payload + elif structured_key is not None: + structured = {structured_key: payload} + + result: dict[str, Any] = { + "content": [{"type": "text", "text": json.dumps(payload)}], + } + if structured is not None: + result["structuredContent"] = structured + return result + + +def _text_result( + text: str, + *, + structured: dict[str, Any] | None = None, + is_error: bool = False, +) -> dict[str, Any]: + """Wrap *text* in the helper response shape.""" + result: dict[str, Any] = { + "content": [{"type": "text", "text": text}], + } + if structured is not None: + result["structuredContent"] = structured + if is_error: + result["isError"] = True + return result + + +def _response_from_payload(payload: Any) -> dict[str, Any]: + """Create a helper response from a normalised payload.""" + if isinstance(payload, str): + return _text_result(payload, structured={"value": payload}) + return _json_result(payload) + + +def _load_mcp_sdk() -> tuple[type[Any], type[Any], type[Any]]: + """Import the optional MCP SDK lazily.""" + try: + fastmcp = importlib.import_module("mcp.server.fastmcp") + types = importlib.import_module("mcp.types") + except ImportError as exc: # pragma: no cover - exercised via tests + raise RuntimeError(_MCP_INSTALL_HINT) from exc + + return fastmcp.FastMCP, types.CallToolResult, types.TextContent + + +def _to_call_tool_result(result: dict[str, Any]) -> Any: + """Convert helper-style results into an MCP SDK CallToolResult.""" + _, call_tool_result_type, text_content_type = _load_mcp_sdk() + content = [ + text_content_type(type="text", text=item["text"]) + for item in result.get("content", []) + if item.get("type") == "text" + ] + return call_tool_result_type( + content=content, + structuredContent=result.get("structuredContent"), + isError=result.get("isError", False), + ) + + +def _safe_default(value: Any) -> Any: + """Return a JSON-safe schema default or a sentinel when unavailable.""" + if value is inspect._empty: + return _NO_DEFAULT + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, (str, bool, int, float)) or value is None: + return value + if isinstance(value, tuple) and all( + isinstance(item, (str, bool, int, float)) or item is None for item in value + ): + return list(value) + if isinstance(value, list) and all( + isinstance(item, (str, bool, int, float)) or item is None for item in value + ): + return value + return _NO_DEFAULT + + +def _wrapper_default(value: Any) -> Any: + """Return a lightweight default for generated wrapper signatures.""" + default = _safe_default(value) + if default is _NO_DEFAULT: + return None + return default + + +def _annotation_label(annotation: Any) -> str: + """Return a readable label for *annotation*.""" + if annotation is inspect._empty: + return "Any" + if isinstance(annotation, str): + return annotation + origin = get_origin(annotation) + if origin is not None: + return str(annotation).replace("typing.", "") + return getattr(annotation, "__name__", repr(annotation)) + + +def _annotation_options(annotation: Any) -> list[Any]: + """Flatten simple union annotations into a list of options.""" + if annotation is inspect._empty: + return [Any] + + origin = get_origin(annotation) + if origin in (None,): + return [annotation] + + if origin in (list, tuple, dict): + return [annotation] + + if origin in (Callable,): + return [annotation] + + args = [arg for arg in get_args(annotation) if arg is not type(None)] + return args or [annotation] + + +def _is_enum_annotation(annotation: Any) -> type[enum.Enum] | None: + """Return the enum class inside *annotation*, if any.""" + for option in _annotation_options(annotation): + if inspect.isclass(option) and issubclass(option, enum.Enum): + return option + return None + + +def _annotation_includes_callable(annotation: Any) -> bool: + """Return whether *annotation* includes a callable type.""" + label = _annotation_label(annotation) + if "Callable" in label: + return True + for option in _annotation_options(annotation): + origin = get_origin(option) + if origin in (Callable,): + return True + return False + + +def _annotation_includes_custom_class(annotation: Any) -> bool: + """Return whether *annotation* includes a non-builtin class.""" + for option in _annotation_options(annotation): + if not inspect.isclass(option): + continue + if issubclass(option, enum.Enum): + return True + if option.__module__ == "builtins": + continue + if option in (date, datetime, time): + continue + return True + return False + + +def _schema_and_py_type( + annotation: Any, *, param_name: str +) -> tuple[dict[str, Any], Any]: + """Map Python annotations to JSON Schema and wrapper annotations.""" + enum_type = _is_enum_annotation(annotation) + if enum_type is not None: + raw_values = [member.value for member in enum_type] + if all(isinstance(item, str) for item in raw_values): + schema = {"type": "string", "enum": list(raw_values)} + return schema, str + if all(isinstance(item, int) for item in raw_values): + schema = {"type": "integer", "enum": list(raw_values)} + return schema, int + + label = _annotation_label(annotation) + lower = label.lower() + + if "bool" in lower: + return {"type": "boolean"}, bool + if "int" in lower and "point" not in lower: + return {"type": "integer"}, int + if ( + "float" in lower + or "number" in lower + or "scalar" in lower + or "ndarray" in lower + or "arraylike" in lower + ): + if "scalarorarray" in lower: + return { + "type": _JSON_ANY_TYPE, + "description": f"Parameter `{param_name}`. {_REFERENCE_HELP}", + }, Any + if "ndarray" in lower or "arraylike" in lower: + return {"type": "array", "items": {}}, list[Any] + return {"type": "number"}, float + if ( + "list" in lower + or "tuple" in lower + or "sequence" in lower + or "iterable" in lower + ): + return {"type": "array", "items": {}}, list[Any] + if "dict" in lower or "mapping" in lower: + return {"type": "object"}, dict[str, Any] + if "str" in lower or "date" in lower or "datetime" in lower or "time" in lower: + return {"type": "string"}, str + if _annotation_includes_callable(annotation) or _annotation_includes_custom_class( + annotation + ): + return { + "type": _JSON_ANY_TYPE, + "description": f"Parameter `{param_name}`. {_REFERENCE_HELP}", + }, Any + return {"type": _JSON_ANY_TYPE}, Any + + +def _build_signature_and_schema( + signature: inspect.Signature, + *, + type_hints: dict[str, Any], +) -> tuple[inspect.Signature, dict[str, Any]]: + """Build a wrapper signature and JSON schema for *signature*.""" + wrapper_parameters: list[inspect.Parameter] = [] + properties: dict[str, Any] = {} + required: list[str] = [] + + for parameter in signature.parameters.values(): + if parameter.name in {"self", "cls"}: + continue + + if parameter.kind == inspect.Parameter.VAR_POSITIONAL: + properties["args"] = { + "type": "array", + "items": {}, + "description": "Extra positional arguments.", + } + wrapper_parameters.append( + inspect.Parameter( + "args", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[Any], + default=None, + ) + ) + continue + + if parameter.kind == inspect.Parameter.VAR_KEYWORD: + properties["kwargs"] = { + "type": "object", + "description": "Extra keyword arguments.", + } + wrapper_parameters.append( + inspect.Parameter( + "kwargs", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=dict[str, Any], + default=None, + ) + ) + continue + + annotation = type_hints.get(parameter.name, parameter.annotation) + schema, py_type = _schema_and_py_type(annotation, param_name=parameter.name) + default = _safe_default(parameter.default) + if default is not _NO_DEFAULT: + schema["default"] = default + else: + required.append(parameter.name) + + properties[parameter.name] = schema + wrapper_parameters.append( + inspect.Parameter( + parameter.name, + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=py_type, + default=( + inspect._empty + if parameter.default is inspect._empty + else _wrapper_default(parameter.default) + ), + ) + ) + + wrapper_signature = inspect.Signature(parameters=wrapper_parameters) + input_schema: dict[str, Any] = { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + } + return wrapper_signature, input_schema + + +def _type_hints_for_target(target: Any) -> dict[str, Any]: + """Return best-effort type hints for *target*.""" + hinted = target.__init__ if inspect.isclass(target) else target + try: + return get_type_hints(hinted, include_extras=True) + except Exception: + return {} + + +def _resolve_public_callable(name: str) -> Any: + """Resolve a public ferro-ta callable by its exposed MCP name.""" + _, targets = _discover_public_callables() + if name in targets: + return targets[name] + aliases = { + "sma": ferro_ta.SMA, + "ema": ferro_ta.EMA, + "rsi": ferro_ta.RSI, + "macd": ferro_ta.MACD, + "backtest": run_backtest, + } + try: + return aliases[name] + except KeyError as exc: + raise KeyError(f"Unknown callable reference: {name!r}") from exc + + +def _coerce_enum(value: Any, enum_type: type[enum.Enum]) -> enum.Enum: + """Convert *value* into *enum_type*.""" + if isinstance(value, enum_type): + return value + if isinstance(value, str): + if value in enum_type.__members__: + return enum_type[value] + for member in enum_type: + if member.value == value: + return member + return enum_type(value) + + +def _decode_value(value: Any, annotation: Any = Any) -> Any: + """Resolve stored-object and callable references inside *value*.""" + if _is_instance_ref(value): + return _get_instance(str(value["instance_id"])) + + if _is_callable_ref(value): + return _resolve_public_callable(str(value["callable"])) + + enum_type = _is_enum_annotation(annotation) + if enum_type is not None: + try: + return _coerce_enum(value, enum_type) + except Exception: + pass + + if _annotation_includes_callable(annotation) and isinstance(value, str): + try: + return _resolve_public_callable(value) + except KeyError: + pass + + if isinstance(value, list): + return [_decode_value(item, Any) for item in value] + + if isinstance(value, tuple): + return tuple(_decode_value(item, Any) for item in value) + + if isinstance(value, dict): + return {str(key): _decode_value(item, Any) for key, item in value.items()} + + return value + + +def _invoke_target( + target: Any, + *, + signature: inspect.Signature, + type_hints: dict[str, Any], + arguments: dict[str, Any], +) -> Any: + """Call *target* with decoded arguments.""" + positional_args: list[Any] = [] + keyword_args: dict[str, Any] = {} + has_varargs = any( + parameter.kind == inspect.Parameter.VAR_POSITIONAL + for parameter in signature.parameters.values() + ) + before_varargs = True + + for parameter in signature.parameters.values(): + if parameter.name in {"self", "cls"}: + continue + + annotation = type_hints.get(parameter.name, parameter.annotation) + + if parameter.kind == inspect.Parameter.VAR_POSITIONAL: + before_varargs = False + extra_args = arguments.get("args", []) or [] + positional_args.extend(_decode_value(item, Any) for item in extra_args) + continue + + if parameter.kind == inspect.Parameter.VAR_KEYWORD: + extra_kwargs = arguments.get("kwargs", {}) or {} + if not isinstance(extra_kwargs, dict): + raise TypeError("kwargs must be a JSON object") + keyword_args.update( + { + str(key): _decode_value(item, Any) + for key, item in extra_kwargs.items() + } + ) + continue + + expects_positional = ( + before_varargs + and has_varargs + and parameter.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + ) + + if parameter.name in arguments: + decoded = _decode_value(arguments[parameter.name], annotation) + elif parameter.default is not inspect._empty: + if expects_positional: + decoded = parameter.default + else: + continue + else: + raise KeyError(f"Missing required argument: {parameter.name}") + + if expects_positional or parameter.kind == inspect.Parameter.POSITIONAL_ONLY: + positional_args.append(decoded) + else: + keyword_args[parameter.name] = decoded + + return target(*positional_args, **keyword_args) + + +def _describe_instance_payload(identifier: str) -> dict[str, Any]: + """Return a detailed description for a stored instance.""" + value = _get_instance(identifier) + payload = dict(_INSTANCE_META.get(identifier, {})) + payload.update( + { + "instance_id": identifier, + "module": type(value).__module__, + "class_name": type(value).__name__, + "methods": _public_method_summaries(value), + } + ) + return payload + + +def _list_instances_payload() -> list[dict[str, Any]]: + """Return current stored-object metadata.""" + return [ + _describe_instance_payload(identifier) for identifier in sorted(_INSTANCE_STORE) + ] + + +def _build_public_tool_spec(item: dict[str, str], target: Any) -> _ToolSpec: + """Create a generated tool spec for a public ferro-ta callable.""" + is_enum = inspect.isclass(target) and issubclass(target, enum.Enum) + type_hints = _type_hints_for_target(target) + + if is_enum: + signature = inspect.Signature( + [ + inspect.Parameter( + "value", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ) + ] + ) + wrapper_signature, input_schema = _build_signature_and_schema( + signature, + type_hints={"value": str}, + ) + description = ( + item["doc"] + or f"Construct a {item['name']} enum member. Returns a stored instance reference." + ) + + def invoke_enum( + arguments: dict[str, Any], *, enum_type: type[enum.Enum] = target + ) -> Any: + if "value" not in arguments: + raise KeyError("Missing required argument: value") + member = _coerce_enum(arguments["value"], enum_type) + return _instance_ref(member, source_tool=item["name"]) + + return _ToolSpec( + name=item["name"], + description=description, + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke_enum, + ) + + signature = inspect.signature(target) + wrapper_signature, input_schema = _build_signature_and_schema( + signature, + type_hints=type_hints, + ) + is_class = inspect.isclass(target) + description = item["doc"] or f"Call {item['name']}." + if is_class: + description = ( + item["doc"] + or f"Construct a {item['name']} instance. Returns a stored instance reference." + ) + + def invoke_target( + arguments: dict[str, Any], + *, + raw_target: Any = target, + raw_signature: inspect.Signature = signature, + raw_type_hints: dict[str, Any] = type_hints, + returns_instance: bool = is_class, + source_tool: str = item["name"], + ) -> Any: + result = _invoke_target( + raw_target, + signature=raw_signature, + type_hints=raw_type_hints, + arguments=arguments, + ) + if returns_instance: + return _instance_ref(result, source_tool=source_tool) + return _normalise_json(result) + + return _ToolSpec( + name=item["name"], + description=description, + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke_target, + ) + + +def _legacy_series_tool( + name: str, + *, + indicator_name: str, + description: str, +) -> _ToolSpec: + """Return a legacy lowercase indicator alias tool.""" + wrapper_signature = inspect.Signature( + [ + inspect.Parameter( + "close", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[float], + default=inspect._empty, + ), + inspect.Parameter( + "timeperiod", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=14, + ), + ] + ) + input_schema = { + "type": "object", + "properties": { + "close": { + "type": "array", + "items": {"type": "number"}, + "description": "Close price series.", + }, + "timeperiod": { + "type": "integer", + "description": "Look-back period.", + "default": 14, + }, + }, + "required": ["close"], + "additionalProperties": False, + } + + def invoke(arguments: dict[str, Any]) -> Any: + close = np.asarray(arguments["close"], dtype=np.float64) + timeperiod = int(arguments.get("timeperiod", 14)) + return _normalise_json( + compute_indicator(indicator_name, close, timeperiod=timeperiod) + ) + + return _ToolSpec( + name=name, + description=description, + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke, + ) + + +def _legacy_macd_tool() -> _ToolSpec: + """Return the legacy lowercase MACD alias tool.""" + wrapper_signature = inspect.Signature( + [ + inspect.Parameter( + "close", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[float], + default=inspect._empty, + ), + inspect.Parameter( + "fastperiod", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=12, + ), + inspect.Parameter( + "slowperiod", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=26, + ), + inspect.Parameter( + "signalperiod", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=9, + ), + ] + ) + input_schema = { + "type": "object", + "properties": { + "close": { + "type": "array", + "items": {"type": "number"}, + "description": "Close price series.", + }, + "fastperiod": { + "type": "integer", + "description": "Fast EMA period.", + "default": 12, + }, + "slowperiod": { + "type": "integer", + "description": "Slow EMA period.", + "default": 26, + }, + "signalperiod": { + "type": "integer", + "description": "Signal EMA period.", + "default": 9, + }, + }, + "required": ["close"], + "additionalProperties": False, + } + + def invoke(arguments: dict[str, Any]) -> Any: + close = np.asarray(arguments["close"], dtype=np.float64) + result = compute_indicator( + "MACD", + close, + fastperiod=int(arguments.get("fastperiod", 12)), + slowperiod=int(arguments.get("slowperiod", 26)), + signalperiod=int(arguments.get("signalperiod", 9)), + ) + return _normalise_json(result) + + return _ToolSpec( + name="macd", + description=( + "Compute MACD (Moving Average Convergence/Divergence). " + "Returns the line, signal, and histogram." + ), + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke, + ) + + +def _legacy_backtest_tool() -> _ToolSpec: + """Return the legacy lowercase backtest alias tool.""" + wrapper_signature = inspect.Signature( + [ + inspect.Parameter( + "close", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[float], + default=inspect._empty, + ), + inspect.Parameter( + "strategy", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default="rsi_30_70", + ), + inspect.Parameter( + "commission_per_trade", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=float, + default=0.0, + ), + inspect.Parameter( + "slippage_bps", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=float, + default=0.0, + ), + ] + ) + input_schema = { + "type": "object", + "properties": { + "close": { + "type": "array", + "items": {"type": "number"}, + "description": "Close price series.", + }, + "strategy": { + "type": "string", + "description": ( + "Strategy name: 'rsi_30_70', 'sma_crossover', or 'macd_crossover'." + ), + "default": "rsi_30_70", + }, + "commission_per_trade": { + "type": "number", + "description": "Fixed commission per trade.", + "default": 0.0, + }, + "slippage_bps": { + "type": "number", + "description": "Slippage in basis points.", + "default": 0.0, + }, + }, + "required": ["close"], + "additionalProperties": False, + } + + def invoke(arguments: dict[str, Any]) -> Any: + close = np.asarray(arguments["close"], dtype=np.float64) + result = run_backtest( + str(arguments.get("strategy", "rsi_30_70")), + close, + commission_per_trade=float(arguments.get("commission_per_trade", 0.0)), + slippage_bps=float(arguments.get("slippage_bps", 0.0)), + ) + return _normalise_json(result) + + return _ToolSpec( + name="backtest", + description=( + "Run a vectorized backtest on close prices using a named strategy. " + "Returns final equity, trade count, and the equity curve." + ), + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke, + ) + + +def _instance_management_specs() -> list[_ToolSpec]: + """Return generic stored-object management tools.""" + list_signature = inspect.Signature([]) + simple_schema = { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + } + + describe_signature = inspect.Signature( + [ + inspect.Parameter( + "instance_id", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ) + ] + ) + describe_schema = { + "type": "object", + "properties": { + "instance_id": { + "type": "string", + "description": "Stored object identifier.", + } + }, + "required": ["instance_id"], + "additionalProperties": False, + } + + call_signature = inspect.Signature( + [ + inspect.Parameter( + "instance_id", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ), + inspect.Parameter( + "method", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ), + inspect.Parameter( + "args", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[Any], + default=None, + ), + inspect.Parameter( + "kwargs", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=dict[str, Any], + default=None, + ), + ] + ) + call_schema = { + "type": "object", + "properties": { + "instance_id": { + "type": "string", + "description": "Stored object identifier.", + }, + "method": { + "type": "string", + "description": "Public method name.", + }, + "args": { + "type": "array", + "items": {}, + "description": "Optional positional arguments.", + }, + "kwargs": { + "type": "object", + "description": f"Optional keyword arguments. {_REFERENCE_HELP}", + }, + }, + "required": ["instance_id", "method"], + "additionalProperties": False, + } + + callable_signature = inspect.Signature( + [ + inspect.Parameter( + "instance_id", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ), + inspect.Parameter( + "args", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[Any], + default=None, + ), + inspect.Parameter( + "kwargs", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=dict[str, Any], + default=None, + ), + ] + ) + callable_schema = { + "type": "object", + "properties": { + "instance_id": { + "type": "string", + "description": "Stored callable identifier.", + }, + "args": { + "type": "array", + "items": {}, + "description": "Optional positional arguments.", + }, + "kwargs": { + "type": "object", + "description": f"Optional keyword arguments. {_REFERENCE_HELP}", + }, + }, + "required": ["instance_id"], + "additionalProperties": False, + } + + def list_instances_tool(arguments: dict[str, Any]) -> Any: + del arguments + return _list_instances_payload() + + def describe_instance_tool(arguments: dict[str, Any]) -> Any: + return _describe_instance_payload(str(arguments["instance_id"])) + + def call_instance_method_tool(arguments: dict[str, Any]) -> Any: + value = _get_instance(str(arguments["instance_id"])) + method_name = str(arguments["method"]) + if method_name.startswith("_"): + raise ValueError("Only public methods can be called") + method = getattr(value, method_name) + if not callable(method): + raise TypeError( + f"{method_name!r} is not callable on {arguments['instance_id']!r}" + ) + args = [_decode_value(item, Any) for item in (arguments.get("args") or [])] + kwargs = { + str(key): _decode_value(item, Any) + for key, item in (arguments.get("kwargs") or {}).items() + } + return _normalise_json(method(*args, **kwargs)) + + def call_stored_callable_tool(arguments: dict[str, Any]) -> Any: + value = _get_instance(str(arguments["instance_id"])) + if not callable(value): + raise TypeError(f"{arguments['instance_id']!r} is not callable") + args = [_decode_value(item, Any) for item in (arguments.get("args") or [])] + kwargs = { + str(key): _decode_value(item, Any) + for key, item in (arguments.get("kwargs") or {}).items() + } + return _normalise_json(value(*args, **kwargs)) + + def delete_instance_tool(arguments: dict[str, Any]) -> Any: + identifier = str(arguments["instance_id"]) + payload = _describe_instance_payload(identifier) + _INSTANCE_STORE.pop(identifier) + _INSTANCE_META.pop(identifier, None) + return {"deleted": True, **payload} + + return [ + _ToolSpec( + name="list_instances", + description="List stored MCP object references created during this session.", + input_schema=simple_schema, + wrapper_signature=list_signature, + invoke=list_instances_tool, + ), + _ToolSpec( + name="describe_instance", + description="Describe a stored MCP object reference and list its public methods.", + input_schema=describe_schema, + wrapper_signature=describe_signature, + invoke=describe_instance_tool, + ), + _ToolSpec( + name="call_instance_method", + description="Call a public method on a stored MCP object reference.", + input_schema=call_schema, + wrapper_signature=call_signature, + invoke=call_instance_method_tool, + ), + _ToolSpec( + name="call_stored_callable", + description="Invoke a stored callable object reference.", + input_schema=callable_schema, + wrapper_signature=callable_signature, + invoke=call_stored_callable_tool, + ), + _ToolSpec( + name="delete_instance", + description="Delete a stored MCP object reference.", + input_schema=describe_schema, + wrapper_signature=describe_signature, + invoke=delete_instance_tool, + ), + ] + + +@lru_cache(maxsize=1) +def _tool_catalog() -> dict[str, _ToolSpec]: + """Return the full MCP tool catalog.""" + catalog: dict[str, _ToolSpec] = {} + + legacy_specs = [ + _legacy_series_tool( + "sma", + indicator_name="SMA", + description="Compute the Simple Moving Average (SMA) of a price series.", + ), + _legacy_series_tool( + "ema", + indicator_name="EMA", + description="Compute the Exponential Moving Average (EMA) of a price series.", + ), + _legacy_series_tool( + "rsi", + indicator_name="RSI", + description="Compute the Relative Strength Index (RSI) of a price series.", + ), + _legacy_macd_tool(), + _legacy_backtest_tool(), + ] + for spec in legacy_specs: + catalog[spec.name] = spec + + public_entries, targets = _discover_public_callables() + for item in public_entries: + spec = _build_public_tool_spec(item, targets[item["name"]]) + catalog[spec.name] = spec + + for spec in _instance_management_specs(): + catalog[spec.name] = spec + + return catalog + + +def _make_fastmcp_wrapper(spec: _ToolSpec) -> Callable[..., Any]: + """Create a FastMCP-friendly wrapper for *spec*.""" + + def wrapper(**kwargs: Any) -> Any: + return _to_call_tool_result(handle_call_tool(spec.name, dict(kwargs))) + + wrapper.__name__ = f"tool_{_slugify(spec.name).replace('-', '_')}" + wrapper.__doc__ = spec.description + setattr(wrapper, "__signature__", spec.wrapper_signature) + wrapper.__annotations__ = { + parameter.name: ( + Any if parameter.annotation is inspect._empty else parameter.annotation + ) + for parameter in spec.wrapper_signature.parameters.values() + } + wrapper.__annotations__["return"] = Any + return wrapper + + +def handle_list_tools() -> dict[str, Any]: + """Return the MCP ListTools response.""" + return { + "tools": [ + { + "name": spec.name, + "description": spec.description, + "inputSchema": spec.input_schema, + } + for spec in _tool_catalog().values() + ] + } + + +def handle_call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Dispatch an MCP CallTool request and return helper-style content.""" + try: + spec = _tool_catalog().get(name) + if spec is None: + return _text_result( + f"Unknown tool: {name!r}", + structured={"error": f"Unknown tool: {name!r}"}, + is_error=True, + ) + + payload = spec.invoke(arguments) + return _response_from_payload(payload) + + except Exception as exc: + return _text_result( + f"Error: {exc}", + structured={"error": str(exc)}, + is_error=True, + ) + + +@lru_cache(maxsize=1) +def create_server() -> Any: + """Create the FastMCP server lazily so MCP stays optional.""" + fast_mcp_type, _, _ = _load_mcp_sdk() + app = fast_mcp_type( + "ferro-ta", + instructions=( + "Expose ferro-ta's public API over MCP. " + "Use exact public ferro-ta names such as SMA, RSI, compute_indicator, " + "TickAggregator, or AlertManager, or the legacy aliases sma, ema, rsi, " + "macd, and backtest. Use list_instances, describe_instance, " + "call_instance_method, call_stored_callable, and delete_instance for " + "stateful objects and stored callables." + ), + ) + + for spec in _tool_catalog().values(): + app.add_tool( + _make_fastmcp_wrapper(spec), name=spec.name, description=spec.description + ) + + return app + + +def run_server() -> None: # pragma: no cover + """Run the stdio MCP server.""" + try: + create_server().run(transport="stdio") + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc diff --git a/ferro-ta-main/python/ferro_ta/mcp/__main__.py b/ferro-ta-main/python/ferro_ta/mcp/__main__.py new file mode 100644 index 0000000..29bd240 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/mcp/__main__.py @@ -0,0 +1,6 @@ +"""Entry point so the MCP server can be run as ``python -m ferro_ta.mcp``.""" + +from ferro_ta.mcp import run_server + +if __name__ == "__main__": + run_server() # pragma: no cover diff --git a/ferro-ta-main/python/ferro_ta/py.typed b/ferro-ta-main/python/ferro_ta/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ferro-ta-main/python/ferro_ta/tools/__init__.py b/ferro-ta-main/python/ferro_ta/tools/__init__.py new file mode 100644 index 0000000..af60100 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/__init__.py @@ -0,0 +1,29 @@ +""" +ferro_ta.tools — Developer tools, visualisation, alerting, and workflow utilities. + +Sub-modules +----------- +* :mod:`ferro_ta.tools.tools` — General-purpose utility helpers (compute_indicator, run_backtest, …) +* :mod:`ferro_ta.tools.viz` — Charting and visualisation API (matplotlib) +* :mod:`ferro_ta.tools.dashboard`— Interactive Streamlit/Dash dashboard helpers +* :mod:`ferro_ta.tools.alerts` — Alert manager and threshold checks +* :mod:`ferro_ta.tools.dsl` — Strategy expression DSL +* :mod:`ferro_ta.tools.pipeline` — Indicator pipeline builder +* :mod:`ferro_ta.tools.workflow` — Workflow automation helpers +* :mod:`ferro_ta.tools.api_info` — API discovery helpers (:func:`indicators`, :func:`info`) +* :mod:`ferro_ta.tools.gpu` — GPU-accelerated indicator support (requires PyTorch) + +Example usage:: + + from ferro_ta.tools import compute_indicator, run_backtest, list_indicators + from ferro_ta.tools.alerts import check_cross +""" + +# Re-export the stable public API from tools.tools. +# tools/tools.py has no ferro_ta module-level imports, so this is safe. +from ferro_ta.tools.tools import ( # noqa: F401 + compute_indicator, + describe_indicator, + list_indicators, + run_backtest, +) diff --git a/ferro-ta-main/python/ferro_ta/tools/alerts.py b/ferro-ta-main/python/ferro_ta/tools/alerts.py new file mode 100644 index 0000000..6e54f23 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/alerts.py @@ -0,0 +1,432 @@ +""" +ferro_ta.alerts — Alerts and notification hooks. +================================================ + +Provides an ``AlertManager`` for registering conditions (threshold crossings, +series cross-overs) and dispatching events to callbacks and/or webhooks. +Supports both **backtest** mode (collect alerts in a list for analysis) and +**live** mode (invoke callbacks or POST to webhook URLs on each condition fire). + +Quick start +----------- +>>> import numpy as np +>>> from ferro_ta.tools.alerts import AlertManager +>>> np.random.seed(0) +>>> close = 100 + np.cumsum(np.random.randn(200) * 0.5) +>>> from ferro_ta import RSI +>>> rsi = RSI(close, timeperiod=14) +>>> am = AlertManager() +>>> am.add_threshold_condition("rsi_oversold", rsi, level=30, direction=-1) +>>> am.add_threshold_condition("rsi_overbought", rsi, level=70, direction=1) +>>> fired = am.run_backtest() +>>> print(fired) + +API +--- +AlertManager + Registry for conditions and callbacks. Use ``add_threshold_condition`` + or ``add_cross_condition`` to register conditions, then call + ``run_backtest()`` to evaluate all conditions at once. + +check_threshold(series, level, direction) + Low-level: return int8 mask — 1 where *series* crosses *level*. + +check_cross(fast, slow) + Low-level: return int8 mask — 1 (cross up), -1 (cross down), 0 (no cross). + +collect_alert_bars(mask) + Low-level: return indices where *mask* is non-zero. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import check_cross as _rust_check_cross +from ferro_ta._ferro_ta import check_threshold as _rust_check_threshold +from ferro_ta._ferro_ta import collect_alert_bars as _rust_collect_alert_bars +from ferro_ta._utils import _to_f64 + +_log = logging.getLogger(__name__) + +__all__ = [ + "AlertEvent", + "AlertManager", + "check_threshold", + "check_cross", + "collect_alert_bars", +] + + +# --------------------------------------------------------------------------- +# Low-level wrappers +# --------------------------------------------------------------------------- + + +def check_threshold( + series: ArrayLike, + level: float, + direction: int, +) -> NDArray[np.int8]: + """Fire an alert when *series* crosses a threshold *level*. + + Parameters + ---------- + series : array-like — indicator values (e.g. RSI close prices) + level : float — threshold value + direction : int + ``1`` → fire when *series* crosses **above** *level*. + ``-1`` → fire when *series* crosses **below** *level*. + + Returns + ------- + numpy.ndarray of int8 — 1 at the bar where the crossing occurs, 0 elsewhere. + """ + return np.asarray( + _rust_check_threshold(_to_f64(series), float(level), int(direction)), + dtype=np.int8, + ) + + +def check_cross( + fast: ArrayLike, + slow: ArrayLike, +) -> NDArray[np.int8]: + """Detect cross-over / cross-under events between two series. + + Parameters + ---------- + fast : array-like — the "fast" series (e.g. short SMA) + slow : array-like — the "slow" series (e.g. long SMA) + + Returns + ------- + numpy.ndarray of int8: + ``1`` at bars where *fast* crosses **above** *slow* (bullish). + ``-1`` at bars where *fast* crosses **below** *slow* (bearish). + ``0`` elsewhere. + """ + return np.asarray( + _rust_check_cross(_to_f64(fast), _to_f64(slow)), + dtype=np.int8, + ) + + +def collect_alert_bars(mask: ArrayLike) -> NDArray[np.int64]: + """Return bar indices where *mask* is non-zero (condition fired). + + Parameters + ---------- + mask : array-like of int8 — output of ``check_threshold`` or ``check_cross`` + + Returns + ------- + numpy.ndarray of int64 — indices of fired bars (ascending order) + """ + m = np.asarray(mask, dtype=np.int8) + return np.asarray(_rust_collect_alert_bars(m), dtype=np.int64) + + +# --------------------------------------------------------------------------- +# AlertEvent +# --------------------------------------------------------------------------- + + +class AlertEvent: + """A single alert event. + + Attributes + ---------- + condition_id : str — user-supplied condition name + bar_index : int — bar index where the condition fired + value : float or None — optional series value at the fired bar + payload : dict — extra metadata (e.g. symbol, direction) + """ + + __slots__ = ("condition_id", "bar_index", "value", "payload") + + def __init__( + self, + condition_id: str, + bar_index: int, + value: Optional[float] = None, + payload: Optional[dict[str, Any]] = None, + ) -> None: + self.condition_id = condition_id + self.bar_index = bar_index + self.value = value + self.payload = payload or {} + + def __repr__(self) -> str: + return ( + f"AlertEvent(condition_id={self.condition_id!r}, " + f"bar_index={self.bar_index}, value={self.value})" + ) + + def to_dict(self) -> dict[str, Any]: + """Return event as a plain dict (suitable for JSON serialisation).""" + return { + "condition_id": self.condition_id, + "bar_index": self.bar_index, + "value": self.value, + **self.payload, + } + + +# --------------------------------------------------------------------------- +# Internal dataclass for condition storage +# --------------------------------------------------------------------------- + + +@dataclass +class _AlertCondition: + """Internal representation of a registered alert condition.""" + + kind: str # "threshold" or "cross" + condition_id: str + series_a: np.ndarray # primary series (or fast series for cross) + series_b: Optional[np.ndarray] # slow series for cross, else None + level: Optional[float] # threshold level (threshold only) + direction: Optional[int] # +1 / -1 (threshold) or None (cross) + callback: Optional[Callable[..., Any]] + webhook_url: Optional[str] + extra_payload: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# AlertManager +# --------------------------------------------------------------------------- + + +class AlertManager: + """Registry for alert conditions. + + Supports both **backtest** mode (collect events in a list) and + **live** mode (dispatch via callback and/or webhook). + + Parameters + ---------- + symbol : str, optional + Symbol name included in every event payload. + live : bool + If ``True``, ``run_live()`` is used and callbacks/webhooks are invoked + immediately. In backtest mode (``live=False``, default) no external + calls are made unless ``force_live=True`` in ``run_backtest()``. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.alerts import AlertManager + >>> from ferro_ta import RSI, SMA + >>> close = np.cumprod(1 + np.random.randn(100) * 0.01) * 100 + >>> rsi = RSI(close) + >>> sma20 = SMA(close, 20) + >>> sma50 = SMA(close, 50) + >>> am = AlertManager(symbol="BTC") + >>> am.add_threshold_condition("rsi_os", rsi, level=30, direction=-1) + >>> am.add_cross_condition("sma_x", sma20, sma50) + >>> events = am.run_backtest() + >>> for ev in events: + ... print(ev) + """ + + def __init__( + self, + symbol: str = "", + live: bool = False, + ) -> None: + self._symbol = symbol + self._live = live + self._conditions: list[_AlertCondition] = [] + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def add_threshold_condition( + self, + condition_id: str, + series: ArrayLike, + level: float, + direction: int, + callback: Optional[Callable[[AlertEvent], None]] = None, + webhook_url: Optional[str] = None, + **extra_payload: Any, + ) -> None: + """Register a threshold crossing condition. + + Parameters + ---------- + condition_id : str — unique name for this condition + series : array-like — the indicator / price series to watch + level : float — threshold level + direction : int — ``1`` (cross above) or ``-1`` (cross below) + callback : callable, optional — ``callback(event)`` invoked on fire + webhook_url : str, optional — HTTP POST target (live mode only) + **extra_payload : extra keys merged into ``AlertEvent.payload`` + """ + self._conditions.append( + _AlertCondition( + kind="threshold", + condition_id=condition_id, + series_a=np.asarray(series, dtype=np.float64), + series_b=None, + level=float(level), + direction=int(direction), + callback=callback, + webhook_url=webhook_url, + extra_payload=dict(extra_payload), + ) + ) + + def add_cross_condition( + self, + condition_id: str, + fast: ArrayLike, + slow: ArrayLike, + callback: Optional[Callable[[AlertEvent], None]] = None, + webhook_url: Optional[str] = None, + **extra_payload: Any, + ) -> None: + """Register a series cross-over / cross-under condition. + + Parameters + ---------- + condition_id : str — unique name for this condition + fast : array-like — the "fast" series + slow : array-like — the "slow" series + callback : callable, optional — ``callback(event)`` invoked on fire + webhook_url : str, optional — HTTP POST target (live mode only) + **extra_payload : extra keys merged into ``AlertEvent.payload`` + """ + self._conditions.append( + _AlertCondition( + kind="cross", + condition_id=condition_id, + series_a=np.asarray(fast, dtype=np.float64), + series_b=np.asarray(slow, dtype=np.float64), + level=None, + direction=None, + callback=callback, + webhook_url=webhook_url, + extra_payload=dict(extra_payload), + ) + ) + + # ------------------------------------------------------------------ + # Evaluation + # ------------------------------------------------------------------ + + def run_backtest( + self, + force_live: bool = False, + ) -> list[AlertEvent]: + """Evaluate all registered conditions in batch (backtest mode). + + No callbacks or webhooks are invoked unless ``force_live=True``. + + Parameters + ---------- + force_live : bool + If ``True``, invoke callbacks and webhooks even in backtest mode. + + Returns + ------- + list of :class:`AlertEvent` — all events that fired, sorted by bar + index (then condition_id for ties). + """ + events: list[AlertEvent] = [] + do_live = self._live or force_live + + for cond in self._conditions: + if cond.kind == "threshold": + mask = _rust_check_threshold( + np.ascontiguousarray(cond.series_a, dtype=np.float64), + float(cond.level), # type: ignore[arg-type] + int(cond.direction), # type: ignore[arg-type] + ) + bars = _rust_collect_alert_bars(mask) + for bar_idx in bars: + ev = AlertEvent( + condition_id=cond.condition_id, + bar_index=int(bar_idx), + value=float(cond.series_a[int(bar_idx)]), + payload={ + "symbol": self._symbol, + "direction": int(cond.direction), # type: ignore[arg-type] + **cond.extra_payload, + }, + ) + events.append(ev) + if do_live: + self._dispatch(ev, cond.callback, cond.webhook_url) + elif cond.kind == "cross": + mask = _rust_check_cross( + np.ascontiguousarray(cond.series_a, dtype=np.float64), + np.ascontiguousarray(cond.series_b, dtype=np.float64), # type: ignore[arg-type] + ) + bars = _rust_collect_alert_bars(mask) + for bar_idx in bars: + cross_dir = int(mask[int(bar_idx)]) + ev = AlertEvent( + condition_id=cond.condition_id, + bar_index=int(bar_idx), + value=float(cond.series_a[int(bar_idx)]), + payload={ + "symbol": self._symbol, + "direction": cross_dir, + **cond.extra_payload, + }, + ) + events.append(ev) + if do_live: + self._dispatch(ev, cond.callback, cond.webhook_url) + + events.sort(key=lambda e: (e.bar_index, e.condition_id)) + return events + + # ------------------------------------------------------------------ + # Dispatch helpers + # ------------------------------------------------------------------ + + @staticmethod + def _dispatch( + event: AlertEvent, + callback: Optional[Callable[[AlertEvent], None]], + webhook_url: Optional[str], + ) -> None: + """Invoke callback and/or HTTP POST to webhook.""" + if callback is not None: + try: + callback(event) + except Exception as exc: # noqa: BLE001 + _log.warning("Alert callback raised an exception: %s", exc) + + if webhook_url: + AlertManager._post_webhook(webhook_url, event.to_dict()) + + @staticmethod + def _post_webhook(url: str, payload: dict[str, Any]) -> None: + """HTTP POST *payload* as JSON to *url* (best-effort, no retry).""" + import urllib.error + import urllib.request + + try: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={"Content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5): + pass + except (urllib.error.URLError, OSError, ValueError) as exc: + _log.warning("Webhook POST to %s failed: %s", url, exc) diff --git a/ferro-ta-main/python/ferro_ta/tools/api_info.py b/ferro-ta-main/python/ferro_ta/tools/api_info.py new file mode 100644 index 0000000..5196252 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/api_info.py @@ -0,0 +1,300 @@ +""" +ferro_ta.api_info — API discovery helpers. + +Provides :func:`indicators`, :func:`methods`, :func:`about`, and :func:`info` +for exploring the ferro_ta public API without reading source code. + +Usage +----- +>>> import ferro_ta +>>> ferro_ta.indicators() # all indicators, sorted +>>> ferro_ta.indicators(category="momentum") # filter by category +>>> ferro_ta.methods() # public callables across modules +>>> ferro_ta.about()["version"] # package metadata summary +>>> ferro_ta.info(ferro_ta.SMA) # parameter docs for SMA + +API +--- +indicators(category=None) — Return list of dicts describing every indicator. +methods(category=None) — Return list of public callables across modules. +about() — Return package/version/module summary metadata. +info(func_or_name) — Return a dict with full signature/docstring info. +""" + +from __future__ import annotations + +import importlib +import inspect +from typing import Any + +__all__ = ["indicators", "methods", "about", "info"] + +# --------------------------------------------------------------------------- +# Category → module mapping used by indicators() +# --------------------------------------------------------------------------- + +_CATEGORY_MODULES: dict[str, str] = { + "overlap": "ferro_ta.indicators.overlap", + "momentum": "ferro_ta.indicators.momentum", + "volume": "ferro_ta.indicators.volume", + "volatility": "ferro_ta.indicators.volatility", + "statistic": "ferro_ta.indicators.statistic", + "price_transform": "ferro_ta.indicators.price_transform", + "pattern": "ferro_ta.indicators.pattern", + "cycle": "ferro_ta.indicators.cycle", + "math_ops": "ferro_ta.indicators.math_ops", + "extended": "ferro_ta.indicators.extended", + "batch": "ferro_ta.data.batch", + "streaming": "ferro_ta.data.streaming", + "resampling": "ferro_ta.data.resampling", + "aggregation": "ferro_ta.data.aggregation", + "signals": "ferro_ta.analysis.signals", + "portfolio": "ferro_ta.analysis.portfolio", + "features": "ferro_ta.analysis.features", + "alerts": "ferro_ta.tools.alerts", + "crypto": "ferro_ta.analysis.crypto", + "regime": "ferro_ta.analysis.regime", +} + +_METHOD_MODULES: dict[str, str] = { + "top_level": "ferro_ta", + **_CATEGORY_MODULES, + "options": "ferro_ta.analysis.options", + "futures": "ferro_ta.analysis.futures", + "backtest": "ferro_ta.analysis.backtest", + "options_strategy": "ferro_ta.analysis.options_strategy", + "derivatives_payoff": "ferro_ta.analysis.derivatives_payoff", + "attribution": "ferro_ta.analysis.attribution", + "cross_asset": "ferro_ta.analysis.cross_asset", + "tools": "ferro_ta.tools.tools", + "viz": "ferro_ta.tools.viz", +} + + +def _iter_module_callables( + module_name: str, +) -> list[tuple[str, Any]]: + """Import *module_name* and return its ``__all__`` callables.""" + try: + mod = importlib.import_module(module_name) + except Exception: + return [] + + names = getattr(mod, "__all__", []) + result = [] + for name in names: + obj = getattr(mod, name, None) + if callable(obj): + result.append((name, obj)) + return result + + +def indicators(category: str | None = None) -> list[dict[str, Any]]: + """Return a list of all ferro_ta indicators with metadata. + + Each entry is a dict with the following keys: + + - ``"name"`` (str): The indicator name, e.g. ``"SMA"``. + - ``"category"`` (str): The category / sub-module, e.g. ``"overlap"``. + - ``"module"`` (str): The fully qualified module name. + - ``"doc"`` (str): First line of the docstring, or ``""`` if absent. + - ``"params"`` (list[str]): Names of the function's parameters. + + Parameters + ---------- + category : str | None + If given, only return indicators from that category. Must be one of + the keys in :data:`ferro_ta.api_info._CATEGORY_MODULES`. + + Returns + ------- + list[dict[str, Any]] + Sorted alphabetically by ``"name"``. + + Examples + -------- + >>> import ferro_ta + >>> all_inds = ferro_ta.indicators() + >>> len(all_inds) > 50 + True + >>> overlap_inds = ferro_ta.indicators(category="overlap") + >>> any(d["name"] == "SMA" for d in overlap_inds) + True + """ + cats: dict[str, str] = ( + {category: _CATEGORY_MODULES[category]} + if category is not None + else _CATEGORY_MODULES + ) + result: list[dict[str, Any]] = [] + seen: set[str] = set() + + for cat, mod_name in cats.items(): + for name, func in _iter_module_callables(mod_name): + if name in seen: + continue + seen.add(name) + doc = inspect.getdoc(func) or "" + first_line = doc.splitlines()[0] if doc else "" + try: + sig = inspect.signature(func) + params = list(sig.parameters.keys()) + except (ValueError, TypeError): + params = [] + result.append( + { + "name": name, + "category": cat, + "module": mod_name, + "doc": first_line, + "params": params, + } + ) + + result.sort(key=lambda d: d["name"]) + return result + + +def methods(category: str | None = None) -> list[dict[str, Any]]: + """Return public callables across ferro_ta modules. + + Parameters + ---------- + category : str | None + Optional key from :data:`_METHOD_MODULES`, such as ``"top_level"``, + ``"options"``, ``"futures"``, or ``"batch"``. + """ + cats: dict[str, str] = ( + {category: _METHOD_MODULES[category]} + if category is not None + else _METHOD_MODULES + ) + + result: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for cat, mod_name in cats.items(): + for name, func in _iter_module_callables(mod_name): + key = (mod_name, name) + if key in seen: + continue + seen.add(key) + doc = inspect.getdoc(func) or "" + first_line = doc.splitlines()[0] if doc else "" + try: + sig = inspect.signature(func) + params = list(sig.parameters.keys()) + except (ValueError, TypeError): + params = [] + result.append( + { + "name": name, + "category": cat, + "module": mod_name, + "doc": first_line, + "params": params, + } + ) + + result.sort(key=lambda d: (d["category"], d["name"])) + return result + + +def about() -> dict[str, Any]: + """Return a small metadata summary for the installed ferro_ta package.""" + import ferro_ta # noqa: PLC0415 + + top_level_exports = sorted(getattr(ferro_ta, "__all__", [])) + return { + "name": "ferro-ta", + "version": getattr(ferro_ta, "__version__", "0+unknown"), + "top_level_export_count": len(top_level_exports), + "indicator_count": len(indicators()), + "method_count": len(methods()), + "categories": sorted(_METHOD_MODULES.keys()), + "top_level_exports": top_level_exports, + } + + +def info(func_or_name: Any) -> dict[str, Any]: + """Return detailed information about an indicator function. + + Parameters + ---------- + func_or_name : callable | str + The indicator function (e.g. ``ferro_ta.SMA``) or its name as a + string (e.g. ``"SMA"``). + + Returns + ------- + dict[str, Any] + Dictionary with the following keys: + + - ``"name"`` (str) + - ``"module"`` (str) + - ``"signature"`` (str): Full ``inspect.signature`` string. + - ``"doc"`` (str): Full docstring. + - ``"params"`` (dict[str, dict]): Mapping of parameter name → + ``{"default": ..., "kind": str}`` for each parameter. + + Raises + ------ + ValueError + If *func_or_name* is a string that does not match any indicator. + + Examples + -------- + >>> import ferro_ta + >>> d = ferro_ta.info(ferro_ta.SMA) + >>> d["name"] + 'SMA' + >>> "close" in d["params"] + True + """ + if isinstance(func_or_name, str): + import ferro_ta # noqa: PLC0415 + + func = getattr(ferro_ta, func_or_name, None) + if func is None: + raise ValueError( + f"No indicator named {func_or_name!r} found in ferro_ta. " + "Use ferro_ta.indicators() to list all available indicators." + ) + else: + func = func_or_name + + name = getattr(func, "__name__", repr(func)) + module = getattr(func, "__module__", "") + doc = inspect.getdoc(func) or "" + + try: + sig = inspect.signature(func) + sig_str = str(sig) + params = {} + for pname, param in sig.parameters.items(): + kind_map = { + inspect.Parameter.POSITIONAL_ONLY: "positional_only", + inspect.Parameter.POSITIONAL_OR_KEYWORD: "positional_or_keyword", + inspect.Parameter.VAR_POSITIONAL: "var_positional", + inspect.Parameter.KEYWORD_ONLY: "keyword_only", + inspect.Parameter.VAR_KEYWORD: "var_keyword", + } + params[pname] = { + "default": ( + param.default + if param.default is not inspect.Parameter.empty + else None + ), + "has_default": param.default is not inspect.Parameter.empty, + "kind": kind_map.get(param.kind, "unknown"), + } + except (ValueError, TypeError): + sig_str = "()" + params = {} + + return { + "name": name, + "module": module, + "signature": sig_str, + "doc": doc, + "params": params, + } diff --git a/ferro-ta-main/python/ferro_ta/tools/dashboard.py b/ferro-ta-main/python/ferro_ta/tools/dashboard.py new file mode 100644 index 0000000..3b8c09c --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/dashboard.py @@ -0,0 +1,345 @@ +""" +ferro_ta.dashboard — Interactive dashboards and exploration helpers. +=================================================================== + +Optional helpers for interactive exploration in Jupyter notebooks (via +ipywidgets) and a Streamlit template. All widgets are optional: if ipywidgets +or streamlit are not installed, a clear ``ImportError`` is raised with install +instructions. + +Functions +--------- +indicator_widget(close, indicator_fn, param_name, param_range) + Create an ipywidgets slider that updates an indicator plot in real time. + +backtest_widget(close, strategy_fn, param_name, param_range) + Create an ipywidgets slider that re-runs a backtest and shows equity curve. + +streamlit_app() + Launch a minimal Streamlit dashboard (call from a ``streamlit run`` script). + +Notes +----- +To install optional dependencies:: + + pip install ferro-ta[dashboard] # installs ipywidgets + pip install streamlit # for Streamlit app + +Only the Python layer is in this module — all heavy computation delegated to +existing ferro-ta indicator and backtest functions. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = [ + "indicator_widget", + "backtest_widget", + "streamlit_app", +] + + +# --------------------------------------------------------------------------- +# Jupyter / ipywidgets helpers +# --------------------------------------------------------------------------- + + +def indicator_widget( + close: ArrayLike, + indicator_fn: Callable[..., Any], + param_name: str, + param_range: Sequence[int], + title: str = "Indicator", +) -> Any: + """Create an interactive Jupyter widget with a parameter slider. + + Renders a ``matplotlib`` chart with the close price overlaid by the + indicator output. Dragging the slider updates the chart in real time. + + Parameters + ---------- + close : array-like — close price series + indicator_fn : callable — indicator function, e.g. ``ferro_ta.SMA``. + Signature: ``fn(close, **{param_name: value}) -> ndarray``. + param_name : str — name of the integer parameter to vary (e.g. ``'timeperiod'``). + param_range : sequence of int — values to iterate over (e.g. ``range(5, 51)``). + title : str — chart title. + + Returns + ------- + ipywidgets ``Output`` widget — display it in a Jupyter cell. + + Requires + -------- + ``ipywidgets``, ``matplotlib`` + + Examples + -------- + >>> from ferro_ta import SMA + >>> from ferro_ta.tools.dashboard import indicator_widget + >>> w = indicator_widget(close, SMA, 'timeperiod', range(5, 51)) + >>> display(w) # in a Jupyter cell + """ + try: + import ipywidgets as widgets + import matplotlib.pyplot as plt + except ImportError as exc: + raise ImportError( + "indicator_widget requires ipywidgets and matplotlib.\n" + "Install with: pip install ipywidgets matplotlib" + ) from exc + + c = np.asarray(close, dtype=np.float64) + param_values = list(param_range) + + out = widgets.Output() + + def update(change: Any) -> None: + value = change["new"] + with out: + out.clear_output(wait=True) + fig, ax = plt.subplots(figsize=(12, 4)) + ax.plot(c, label="Close", alpha=0.5) + ind_out = indicator_fn(c, **{param_name: value}) + if isinstance(ind_out, tuple): + for arr in ind_out: + ax.plot(np.asarray(arr, dtype=np.float64), alpha=0.8) + else: + ax.plot( + np.asarray(ind_out, dtype=np.float64), + label=f"{indicator_fn.__name__}({param_name}={value})", + ) + ax.set_title(f"{title} — {param_name}={value}") + ax.legend() + plt.tight_layout() + plt.show() + + slider = widgets.IntSlider( + value=param_values[len(param_values) // 2], + min=min(param_values), + max=max(param_values), + step=1, + description=param_name, + continuous_update=False, + ) + slider.observe(update, names="value") + update({"new": slider.value}) + + return widgets.VBox([slider, out]) + + +def backtest_widget( + close: ArrayLike, + strategy: Union[str, Callable[..., Any]] = "rsi_30_70", + param_name: str = "timeperiod", + param_range: Sequence[int] = range(5, 30), + title: str = "Backtest", +) -> Any: + """Create an interactive Jupyter widget that re-runs a backtest on slider change. + + Parameters + ---------- + close : array-like — close prices + strategy : str or callable — backtest strategy (see ``ferro_ta.backtest.backtest``). + param_name : str — strategy parameter name to vary. + param_range: sequence of int — parameter values to iterate. + title : str — chart title. + + Returns + ------- + ipywidgets ``VBox`` widget. + + Requires + -------- + ``ipywidgets``, ``matplotlib`` + """ + try: + import ipywidgets as widgets + import matplotlib.pyplot as plt + except ImportError as exc: + raise ImportError( + "backtest_widget requires ipywidgets and matplotlib.\n" + "Install with: pip install ipywidgets matplotlib" + ) from exc + + from ferro_ta.analysis.backtest import backtest + + c = np.asarray(close, dtype=np.float64) + param_values = list(param_range) + out = widgets.Output() + + def update(change: Any) -> None: + value = change["new"] + with out: + out.clear_output(wait=True) + result = backtest(c, strategy=strategy, **{param_name: value}) + fig, axes = plt.subplots(2, 1, figsize=(12, 6), sharex=True) + axes[0].plot(c, label="Close", alpha=0.7) + axes[0].set_title(f"{title} — {param_name}={value}") + axes[0].legend() + axes[1].plot(result.equity, label="Equity", color="green") + axes[1].axhline(1.0, color="gray", linestyle="--", alpha=0.5) + axes[1].set_title( + f"Equity (trades={result.n_trades}, final={result.final_equity:.3f})" + ) + axes[1].legend() + plt.tight_layout() + plt.show() + + slider = widgets.IntSlider( + value=param_values[len(param_values) // 2], + min=min(param_values), + max=max(param_values), + step=1, + description=param_name, + continuous_update=False, + ) + slider.observe(update, names="value") + update({"new": slider.value}) + return widgets.VBox([slider, out]) + + +# --------------------------------------------------------------------------- +# Streamlit app template +# --------------------------------------------------------------------------- + + +def streamlit_app() -> None: + """Run a minimal Streamlit TA dashboard. + + Call this function from a Python script and run with:: + + streamlit run your_script.py + + The dashboard provides: + - A file uploader for OHLCV CSV data (or uses synthetic data as fallback). + - An indicator selector (SMA, EMA, RSI, MACD, Bollinger Bands). + - A parameter slider. + - A price + indicator chart. + - A backtest panel (RSI strategy) with equity curve. + + Requires + -------- + ``streamlit``, ``matplotlib`` or ``plotly`` (optional) + + Examples + -------- + Create a file ``ta_dashboard.py``:: + + from ferro_ta.tools.dashboard import streamlit_app + streamlit_app() + + Then run:: + + streamlit run ta_dashboard.py + """ + try: + import streamlit as st + except ImportError as exc: + raise ImportError( + "streamlit_app requires streamlit.\nInstall with: pip install streamlit" + ) from exc + + import ferro_ta as ft + from ferro_ta.analysis.backtest import backtest + + st.title("ferro-ta Interactive Dashboard") + + # ---- Data ---- + st.sidebar.header("Data") + uploaded = st.sidebar.file_uploader("Upload OHLCV CSV", type=["csv"]) + + if uploaded is not None: + try: + import pandas as pd + + df = pd.read_csv(uploaded) + cols = {c.lower(): c for c in df.columns} + close = df[cols["close"]].values.astype(np.float64) + except (ImportError, KeyError, ValueError) as e: + st.error(f"Could not read CSV: {e}") + close = _synthetic_close() + else: + st.info( + "Using synthetic data. Upload a CSV with a 'close' column to use real data." + ) + close = _synthetic_close() + + n = len(close) + st.sidebar.write(f"Bars loaded: {n}") + + # ---- Indicator ---- + st.sidebar.header("Indicator") + indicator_name = st.sidebar.selectbox( + "Indicator", ["SMA", "EMA", "RSI", "MACD", "BBANDS"] + ) + timeperiod = st.sidebar.slider("Period", min_value=2, max_value=200, value=20) + + st.subheader(f"Price + {indicator_name}({timeperiod})") + + try: + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(12, 4)) + ax.plot(close, label="Close", alpha=0.5) + + if indicator_name == "SMA": + ax.plot(np.asarray(ft.SMA(close, timeperiod=timeperiod)), label="SMA") + elif indicator_name == "EMA": + ax.plot(np.asarray(ft.EMA(close, timeperiod=timeperiod)), label="EMA") + elif indicator_name == "RSI": + fig2, ax2 = plt.subplots(figsize=(12, 2)) + ax2.plot( + np.asarray(ft.RSI(close, timeperiod=timeperiod)), + label="RSI", + color="orange", + ) + ax2.axhline(30, color="green", linestyle="--", alpha=0.5) + ax2.axhline(70, color="red", linestyle="--", alpha=0.5) + ax2.set_title("RSI") + st.pyplot(fig2) + elif indicator_name == "MACD": + macd, signal, hist = ft.MACD(close) + ax.plot(np.asarray(macd), label="MACD") + ax.plot(np.asarray(signal), label="Signal") + elif indicator_name == "BBANDS": + upper, middle, lower = ft.BBANDS(close, timeperiod=timeperiod) + ax.plot(np.asarray(upper), label="Upper", linestyle="--") + ax.plot(np.asarray(middle), label="Middle") + ax.plot(np.asarray(lower), label="Lower", linestyle="--") + + ax.legend() + st.pyplot(fig) + except (ImportError, ValueError, RuntimeError) as e: + st.error(f"Error computing indicator: {e}") + + # ---- Backtest panel ---- + st.subheader("Backtest (RSI 30/70 strategy)") + if st.button("Run Backtest"): + result = backtest(close, strategy="rsi_30_70", timeperiod=timeperiod) + try: + import matplotlib.pyplot as plt + + fig3, ax3 = plt.subplots(figsize=(12, 3)) + ax3.plot(result.equity, color="green", label="Equity") + ax3.axhline(1.0, color="gray", linestyle="--") + ax3.set_title( + f"Equity trades={result.n_trades} final={result.final_equity:.4f}" + ) + ax3.legend() + st.pyplot(fig3) + except ImportError: + st.write( + f"Final equity: {result.final_equity:.4f} trades: {result.n_trades}" + ) + + +def _synthetic_close(n: int = 500) -> NDArray: + """Generate a synthetic close price series for the dashboard demo.""" + rng = np.random.default_rng(42) + return np.cumprod(1 + rng.normal(0, 0.01, n)) * 100.0 diff --git a/ferro-ta-main/python/ferro_ta/tools/dsl.py b/ferro-ta-main/python/ferro_ta/tools/dsl.py new file mode 100644 index 0000000..877bec1 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/dsl.py @@ -0,0 +1,525 @@ +""" +ferro_ta.dsl — Strategy expression DSL. + +A small domain-specific language that lets users define rule-based trading +strategies as strings (e.g. ``"RSI(14) < 30 and close > SMA(20)"``) and +evaluate them to produce a boolean or integer signal series. + +This module provides: +- :func:`parse_expression` — validate and compile an expression string. +- :func:`evaluate` — evaluate a compiled expression against OHLCV data. +- :class:`Strategy` — convenience wrapper around parse + evaluate. + +The expression grammar supports: +- Indicator calls: ``RSI(14)``, ``SMA(20)``, ``BBANDS(20, 2)`` +- Price series references: ``close``, ``open``, ``high``, ``low``, ``volume`` +- Comparison operators: ``<``, ``>``, ``<=``, ``>=``, ``==``, ``!=`` +- Logical connectives: ``and``, ``or``, ``not`` +- Cross-above/below helpers: ``cross_above(a, b)``, ``cross_below(a, b)`` +- Parentheses for grouping + +Evaluating an expression returns a 1-D integer array of 1 (signal on) and 0 +(signal off), with leading ``0`` values during indicator warm-up. + +Examples +-------- +>>> import numpy as np +>>> from ferro_ta.tools.dsl import Strategy +>>> rng = np.random.default_rng(0) +>>> close = np.cumprod(1 + rng.normal(0, 0.01, 100)) * 100 +>>> ohlcv = {"close": close} +>>> strat = Strategy("RSI(14) < 30") +>>> signal = strat.evaluate(ohlcv) +>>> signal.shape +(100,) +>>> set(signal.tolist()).issubset({0, 1}) +True +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any, Optional + +import numpy as np +from numpy.typing import NDArray + +from ferro_ta._utils import _to_f64 +from ferro_ta.core.registry import run as _registry_run + +__all__ = [ + "parse_expression", + "evaluate", + "Strategy", +] + +# --------------------------------------------------------------------------- +# Supported indicator / function names (resolved via registry) +# --------------------------------------------------------------------------- + +_PRICE_KEYS = {"close", "open", "high", "low", "volume"} + +# --------------------------------------------------------------------------- +# Expression AST (minimal) +# --------------------------------------------------------------------------- + + +class _Expr: + """Abstract expression node.""" + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + raise NotImplementedError + + +class _PriceRef(_Expr): + def __init__(self, name: str) -> None: + self.name = name + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + if self.name not in ctx: + raise ValueError(f"Price series '{self.name}' not found in OHLCV data.") + return ctx[self.name] + + +class _IndicatorCall(_Expr): + def __init__( + self, + name: str, + args: list[float], + output_index: int = 0, + ) -> None: + self.name = name + self.args = args + self.output_index = output_index + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + close = ctx.get("close") + high = ctx.get("high") + low = ctx.get("low") + volume = ctx.get("volume") + if close is None: + raise ValueError("'close' series is required to evaluate indicator calls.") + kwargs: dict[str, Any] = {} + if self.args: + # Heuristic: first numeric arg → timeperiod + kwargs["timeperiod"] = int(self.args[0]) + # Additional args passed as extra kwargs are not supported in this + # simple DSL; only the first param is used as timeperiod. + + # Try different signatures + result = None + for positional in [ + [close], + [high, low, close] if high is not None and low is not None else None, + [high, low, close, volume] + if volume is not None and high is not None + else None, + ]: + if positional is None: + continue + try: + result = _registry_run(self.name, *positional, **kwargs) + break + except Exception: + continue + if result is None: + raise ValueError( + f"Cannot evaluate indicator '{self.name}' with available data." + ) + + if isinstance(result, tuple): + arr = result[self.output_index] + else: + arr = result + return np.asarray(arr, dtype=np.float64) + + +class _Comparison(_Expr): + _OPS: dict[str, Callable[[Any, Any], Any]] = { + "<": lambda a, b: a < b, + ">": lambda a, b: a > b, + "<=": lambda a, b: a <= b, + ">=": lambda a, b: a >= b, + "==": lambda a, b: a == b, + "!=": lambda a, b: a != b, + } + + def __init__(self, left: _Expr, op: str, right: _Expr) -> None: + self.left = left + self.op = op + self.right = right + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + lv = self.left.eval(ctx) + rv = self.right.eval(ctx) + fn = self._OPS[self.op] + result = fn(lv, rv) + return result.astype(np.int32) + + +class _Logic(_Expr): + def __init__(self, op: str, operands: list[_Expr]) -> None: + self.op = op # 'and' | 'or' + self.operands = operands + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + result = self.operands[0].eval(ctx).astype(bool) + for operand in self.operands[1:]: + v = operand.eval(ctx).astype(bool) + if self.op == "and": + result = result & v + else: + result = result | v + return result.astype(np.int32) + + +class _Not(_Expr): + def __init__(self, operand: _Expr) -> None: + self.operand = operand + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + return (~self.operand.eval(ctx).astype(bool)).astype(np.int32) + + +class _CrossFunc(_Expr): + def __init__(self, direction: str, a: _Expr, b: _Expr) -> None: + self.direction = direction # 'above' | 'below' + self.a = a + self.b = b + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + av = self.a.eval(ctx).astype(np.float64) + bv = self.b.eval(ctx).astype(np.float64) + n = len(av) + result = np.zeros(n, dtype=np.int32) + if self.direction == "above": + for i in range(1, n): + if av[i] > bv[i] and av[i - 1] <= bv[i - 1]: + result[i] = 1 + else: + for i in range(1, n): + if av[i] < bv[i] and av[i - 1] >= bv[i - 1]: + result[i] = 1 + return result + + +class _Scalar(_Expr): + def __init__(self, value: float) -> None: + self.value = value + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + return np.array([self.value]) + + +# --------------------------------------------------------------------------- +# Tokeniser +# --------------------------------------------------------------------------- + +_TOKEN_SPEC = [ + ("NUMBER", r"-?\d+\.?\d*"), + ("AND", r"\band\b"), + ("OR", r"\bor\b"), + ("NOT", r"\bnot\b"), + ("IDENT", r"[A-Za-z_][A-Za-z0-9_]*"), + ("OP", r"<=|>=|==|!=|<|>"), + ("LPAREN", r"\("), + ("RPAREN", r"\)"), + ("COMMA", r","), + ("SKIP", r"\s+"), +] + +_TOKEN_RE = re.compile( + "|".join(f"(?P<{name}>{pattern})" for name, pattern in _TOKEN_SPEC) +) + + +def _tokenise(expr: str) -> list[tuple[str, str]]: + tokens: list[tuple[str, str]] = [] + for m in _TOKEN_RE.finditer(expr): + kind = m.lastgroup + value = m.group() + if kind == "SKIP" or kind is None: + continue + tokens.append((kind, value)) + # Check for unmatched characters + matched_len = sum(len(m.group()) for m in _TOKEN_RE.finditer(expr)) + if matched_len != len(expr.replace(" ", "").replace("\t", "").replace("\n", "")): + # rough check; just skip + pass + return tokens + + +# --------------------------------------------------------------------------- +# Recursive-descent parser +# --------------------------------------------------------------------------- + + +class _Parser: + def __init__(self, tokens: list[tuple[str, str]]) -> None: + self.tokens = tokens + self.pos = 0 + + def peek(self) -> Optional[tuple[str, str]]: + if self.pos < len(self.tokens): + return self.tokens[self.pos] + return None + + def consume(self, kind: Optional[str] = None) -> tuple[str, str]: + tok = self.peek() + if tok is None: + raise ValueError("Unexpected end of expression.") + if kind and tok[0] != kind: + raise ValueError(f"Expected {kind}, got {tok[0]!r} ({tok[1]!r}).") + self.pos += 1 + return tok + + def parse(self) -> _Expr: + expr = self.parse_or() + if self.peek() is not None: + raise ValueError( + f"Unexpected token at position {self.pos}: {self.peek()!r}" + ) + return expr + + def parse_or(self) -> _Expr: + left = self.parse_and() + operands = [left] + while self.peek() and self.peek()[0] == "OR": # type: ignore[index] + self.consume("OR") + operands.append(self.parse_and()) + return operands[0] if len(operands) == 1 else _Logic("or", operands) + + def parse_and(self) -> _Expr: + left = self.parse_not() + operands = [left] + while self.peek() and self.peek()[0] == "AND": # type: ignore[index] + self.consume("AND") + operands.append(self.parse_not()) + return operands[0] if len(operands) == 1 else _Logic("and", operands) + + def parse_not(self) -> _Expr: + if self.peek() and self.peek()[0] == "NOT": # type: ignore[index] + self.consume("NOT") + return _Not(self.parse_not()) + return self.parse_comparison() + + def parse_comparison(self) -> _Expr: + left = self.parse_atom() + tok = self.peek() + if tok and tok[0] == "OP": + op = tok[1] + self.consume("OP") + right = self.parse_atom() + return _Comparison(left, op, right) + return left + + def parse_atom(self) -> _Expr: + tok = self.peek() + if tok is None: + raise ValueError("Unexpected end of expression in atom.") + + if tok[0] == "NUMBER": + self.consume("NUMBER") + return _Scalar(float(tok[1])) + + if tok[0] == "LPAREN": + self.consume("LPAREN") + expr = self.parse_or() + self.consume("RPAREN") + return expr + + if tok[0] == "NOT": + self.consume("NOT") + return _Not(self.parse_comparison()) + + if tok[0] == "IDENT": + name = tok[1] + self.consume("IDENT") + + # Check if followed by '(' + if self.peek() and self.peek()[0] == "LPAREN": # type: ignore[index] + self.consume("LPAREN") + # Parse comma-separated args + args: list[float] = [] + sub_exprs: list[_Expr] = [] + while self.peek() and self.peek()[0] != "RPAREN": # type: ignore[index] + t = self.peek() + if t and t[0] == "NUMBER": + self.consume("NUMBER") + args.append(float(t[1])) + elif t and t[0] == "IDENT": + # nested indicator or price ref used as sub-expression + sub_exprs.append(self.parse_atom()) + if self.peek() and self.peek()[0] == "COMMA": # type: ignore[index] + self.consume("COMMA") + self.consume("RPAREN") + + name_upper = name.upper() + if name_upper == "CROSS_ABOVE": + if len(sub_exprs) < 2: + raise ValueError("cross_above requires two arguments.") + return _CrossFunc("above", sub_exprs[0], sub_exprs[1]) + if name_upper == "CROSS_BELOW": + if len(sub_exprs) < 2: + raise ValueError("cross_below requires two arguments.") + return _CrossFunc("below", sub_exprs[0], sub_exprs[1]) + return _IndicatorCall(name_upper, args) + else: + # Price reference or bare indicator name + name_lower = name.lower() + if name_lower in _PRICE_KEYS: + return _PriceRef(name_lower) + # Treat as indicator with no args + return _IndicatorCall(name.upper(), []) + + raise ValueError(f"Unexpected token: {tok!r}") + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def parse_expression(expr: str) -> _Expr: + """Parse and compile an expression string into an AST. + + Parameters + ---------- + expr : str + Strategy expression, e.g. ``"RSI(14) < 30 and close > SMA(20)"``. + + Returns + ------- + Compiled expression object (internal type). + + Raises + ------ + ValueError + If the expression cannot be parsed. + + Examples + -------- + >>> from ferro_ta.tools.dsl import parse_expression + >>> ast = parse_expression("RSI(14) < 30") + >>> ast is not None + True + """ + if not isinstance(expr, str) or not expr.strip(): + raise ValueError("expr must be a non-empty string.") + tokens = _tokenise(expr.strip()) + parser = _Parser(tokens) + return parser.parse() + + +def evaluate( + expr: Any, + ohlcv: Any, + *, + close_col: str = "close", + high_col: str = "high", + low_col: str = "low", + open_col: str = "open", + volume_col: str = "volume", +) -> NDArray[np.int32]: + """Evaluate a strategy expression against OHLCV data. + + Parameters + ---------- + expr : str or compiled expression + Either a strategy expression string or the result of + :func:`parse_expression`. + ohlcv : dict of arrays, pandas.DataFrame, or array-like + OHLCV data. At minimum ``close`` is required for indicator-only + expressions. + + Returns + ------- + numpy.ndarray of dtype int32 (values 0 or 1), same length as input. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.dsl import evaluate + >>> rng = np.random.default_rng(1) + >>> close = np.cumprod(1 + rng.normal(0, 0.01, 60)) * 100 + >>> signal = evaluate("RSI(14) < 40", {"close": close}) + >>> set(signal.tolist()).issubset({0, 1}) + True + """ + if isinstance(expr, str): + ast = parse_expression(expr) + else: + ast = expr + + # Build context dict + def _extract(col: str, key: str) -> Optional[NDArray]: + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame) and col in ohlcv.columns: + return _to_f64(ohlcv[col].to_numpy()) + except ImportError: + pass + if isinstance(ohlcv, dict) and key in ohlcv: + return _to_f64(ohlcv[key]) + return None + + ctx: dict[str, NDArray[np.float64]] = {} + for col, key in [ + (close_col, "close"), + (high_col, "high"), + (low_col, "low"), + (open_col, "open"), + (volume_col, "volume"), + ]: + val = _extract(col, key) + if val is not None: + ctx[key] = val + + if "close" not in ctx and isinstance(ohlcv, np.ndarray): + ctx["close"] = _to_f64(ohlcv) + + result = ast.eval(ctx) + # Broadcast scalar to full length + n = len(ctx.get("close", np.array([]))) + if result.shape == (1,) and n > 0: + result = np.broadcast_to(result, (n,)).copy() + + # Convert to int32 signal while avoiding warnings when casting NaN/inf. + # For numeric indicator outputs, treat non-finite values as "no signal" (0). + if np.issubdtype(result.dtype, np.floating): + result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0) + return result.astype(np.int32) + + +class Strategy: + """Convenience class for defining and evaluating a strategy expression. + + Parameters + ---------- + expr : str + Strategy expression string. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.dsl import Strategy + >>> rng = np.random.default_rng(42) + >>> close = np.cumprod(1 + rng.normal(0, 0.01, 100)) * 100 + >>> strat = Strategy("RSI(14) < 30") + >>> signal = strat.evaluate({"close": close}) + >>> signal.shape + (100,) + """ + + def __init__(self, expr: str) -> None: + self.expr_str = expr + self._ast = parse_expression(expr) + + def evaluate(self, ohlcv: Any, **kwargs: Any) -> NDArray[np.int32]: + """Evaluate this strategy on *ohlcv* data.""" + return evaluate(self._ast, ohlcv, **kwargs) + + def __repr__(self) -> str: + return f"Strategy({self.expr_str!r})" diff --git a/ferro-ta-main/python/ferro_ta/tools/gpu.py b/ferro-ta-main/python/ferro_ta/tools/gpu.py new file mode 100644 index 0000000..edd38ef --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/gpu.py @@ -0,0 +1,224 @@ +""" +ferro_ta.gpu — Optional GPU-accelerated indicator backend via PyTorch. + +When the caller passes a PyTorch Tensor as input, the GPU path is used and the +result is returned as a PyTorch Tensor. When a NumPy array (or plain Python +sequence) is passed, the standard CPU path is used — there is **no behaviour +change** for existing CPU-only code. + +Install the optional GPU extra to enable this feature: + + pip install "ferro-ta[gpu]" + +Or install PyTorch manually: + + pip install torch + +Usage +----- +>>> import torch +>>> from ferro_ta.tools.gpu import sma, ema, rsi +>>> +>>> close_gpu = torch.tensor([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10], device='cuda') # or 'mps' +>>> result = sma(close_gpu, timeperiod=3) +>>> type(result) # torch.Tensor +>>> result_cpu = result.cpu().numpy() + +See ``docs/gpu-backend.md`` for design notes, limitations, and benchmark data. +""" + +from __future__ import annotations + +from typing import Any, cast + +import numpy as np + +# --------------------------------------------------------------------------- +# PyTorch detection +# --------------------------------------------------------------------------- + +try: + import torch as _torch + + _TORCH_AVAILABLE = True +except ImportError: + _torch = None # type: ignore[assignment] + _TORCH_AVAILABLE = False + + +def _is_torch(arr: object) -> bool: + """Return True when *arr* is a PyTorch Tensor.""" + return ( + _TORCH_AVAILABLE is True + and _torch is not None + and isinstance(arr, _torch.Tensor) + ) + + +def _to_cpu(arr: object) -> np.ndarray: + """Convert a PyTorch Tensor to a NumPy array; pass NumPy arrays through.""" + if _is_torch(arr): + return cast(Any, arr).cpu().numpy() + return np.asarray(arr, dtype=np.float64) + + +def _to_gpu(arr: np.ndarray, device: Any = None) -> Any: + """Move a NumPy array to the GPU (returns torch.Tensor).""" + assert _torch is not None + return _torch.tensor(arr, device=device) + + +# --------------------------------------------------------------------------- +# GPU implementations +# --------------------------------------------------------------------------- + + +def _sma_gpu(close, timeperiod: int): + """SMA on a PyTorch Tensor using cumsum-based rolling mean.""" + if _torch is None: + raise RuntimeError("PyTorch is not installed") + torch = _torch + n = close.shape[0] + result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device) + if timeperiod < 1 or n < timeperiod: + return result + # cumsum-based O(n) rolling sum + cs = torch.cumsum(close, dim=0) + # window sum for index i: cs[i] - cs[i - timeperiod] (i >= timeperiod-1) + win = cs[timeperiod - 1 :] + win = win.clone() + win[1:] -= cs[: len(win) - 1] + result[timeperiod - 1 :] = win / timeperiod + return result + + +def _ema_gpu(close, timeperiod: int): + """EMA on a PyTorch Tensor — SMA-seeded, element-wise loop in Python/PyTorch.""" + if _torch is None: + raise RuntimeError("PyTorch is not installed") + torch = _torch + n = close.shape[0] + result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device) + if timeperiod < 1 or n < timeperiod: + return result + k = 2.0 / (timeperiod + 1.0) + # Seed with SMA of first window (already on GPU) + seed = float(torch.mean(close[:timeperiod]).item()) + result[timeperiod - 1] = seed + # Recurrence on CPU for numerical correctness then move back + close_cpu = close.cpu().numpy() + res_cpu = np.full(n, np.nan) + res_cpu[timeperiod - 1] = seed + prev = seed + for i in range(timeperiod, n): + val = float(close_cpu[i]) * k + prev * (1.0 - k) + res_cpu[i] = val + prev = val + return torch.tensor(res_cpu, dtype=close.dtype, device=close.device) + + +def _rsi_gpu(close, timeperiod: int): + """RSI on a PyTorch Tensor — compute diffs on GPU, finish on CPU.""" + if _torch is None: + raise RuntimeError("PyTorch is not installed") + torch = _torch + n = close.shape[0] + result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device) + if timeperiod < 1 or n <= timeperiod: + return result + # Compute price diffs on GPU + diffs = torch.diff(close).cpu().numpy() # (n-1,) numpy array + # CPU recurrence (Wilder smoothing) + res_cpu = np.full(n, np.nan) + avg_gain = np.mean(np.maximum(diffs[:timeperiod], 0.0)) + avg_loss = np.mean(np.maximum(-diffs[:timeperiod], 0.0)) + rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf + res_cpu[timeperiod] = 100.0 - 100.0 / (1.0 + rs) + for i in range(timeperiod + 1, n): + d = diffs[i - 1] + gain = d if d > 0.0 else 0.0 + loss = -d if d < 0.0 else 0.0 + avg_gain = (avg_gain * (timeperiod - 1) + gain) / timeperiod + avg_loss = (avg_loss * (timeperiod - 1) + loss) / timeperiod + rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf + res_cpu[i] = 100.0 - 100.0 / (1.0 + rs) + return torch.tensor(res_cpu, dtype=close.dtype, device=close.device) + + +# --------------------------------------------------------------------------- +# Public API — PyTorch in → PyTorch out; NumPy in → NumPy out +# --------------------------------------------------------------------------- + + +def sma(close, timeperiod: int = 30): + """Simple Moving Average — GPU-accelerated when *close* is a PyTorch Tensor. + + Parameters + ---------- + close : numpy.ndarray or torch.Tensor + Close price array. + timeperiod : int, default 30 + Look-back window. + + Returns + ------- + numpy.ndarray or torch.Tensor + Same type as *close*. First ``timeperiod - 1`` values are NaN. + """ + if _is_torch(close): + if not close.is_floating_point(): + close = close.float() + return _sma_gpu(close, timeperiod) + # CPU fallback + from ferro_ta import SMA # noqa: PLC0415 + + return SMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod) + + +def ema(close, timeperiod: int = 30): + """Exponential Moving Average — GPU-accelerated when *close* is a PyTorch Tensor. + + Parameters + ---------- + close : numpy.ndarray or torch.Tensor + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray or torch.Tensor — same type as *close*. + """ + if _is_torch(close): + if not close.is_floating_point(): + close = close.float() + return _ema_gpu(close, timeperiod) + from ferro_ta import EMA # noqa: PLC0415 + + return EMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod) + + +def rsi(close, timeperiod: int = 14): + """Relative Strength Index — GPU-accelerated when *close* is a PyTorch Tensor. + + Parameters + ---------- + close : numpy.ndarray or torch.Tensor + timeperiod : int, default 14 + + Returns + ------- + numpy.ndarray or torch.Tensor — same type as *close*. Values in [0, 100]. + """ + if _is_torch(close): + if not close.is_floating_point(): + close = close.float() + return _rsi_gpu(close, timeperiod) + from ferro_ta import RSI # noqa: PLC0415 + + return RSI(np.asarray(close, dtype=np.float64), timeperiod=timeperiod) + + +__all__ = [ + "sma", + "ema", + "rsi", +] diff --git a/ferro-ta-main/python/ferro_ta/tools/pipeline.py b/ferro-ta-main/python/ferro_ta/tools/pipeline.py new file mode 100644 index 0000000..1b9c839 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/pipeline.py @@ -0,0 +1,343 @@ +""" +ferro_ta.pipeline — Indicator Pipeline and Composition API. + +Build reusable pipelines that apply one or more indicators to price arrays +in a single call. A :class:`Pipeline` collects named steps, runs them in +order, and returns the results as a dictionary. + +This module is designed for: + +- Backtesting workflows that need multiple indicators computed on the same data. +- Feature engineering for machine-learning pipelines. +- Batch scenarios where you want all indicator values in one dictionary. + +Usage +----- +>>> import numpy as np +>>> from ferro_ta.tools.pipeline import Pipeline +>>> from ferro_ta import SMA, EMA, RSI +>>> +>>> close = 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]) +>>> +>>> pipe = ( +... Pipeline() +... .add("sma_10", SMA, timeperiod=10) +... .add("ema_10", EMA, timeperiod=10) +... .add("rsi_14", RSI, timeperiod=14) +... ) +>>> results = pipe.run(close) +>>> print(list(results.keys())) +['sma_10', 'ema_10', 'rsi_14'] +>>> results["sma_10"].shape +(15,) + +Chaining convenience +-------------------- +:meth:`Pipeline.add` returns ``self`` so calls can be chained. + +The :func:`make_pipeline` function is a convenience wrapper: + +>>> from ferro_ta.tools.pipeline import make_pipeline +>>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}), +... rsi_14=(RSI, {"timeperiod": 14})) +>>> results = pipe.run(close) + +Multi-output indicators +----------------------- +For indicators that return tuples (e.g. BBANDS, MACD) you can pass an +optional ``output_keys`` argument to unpack the tuple into named keys: + +>>> from ferro_ta import BBANDS, MACD +>>> pipe = ( +... Pipeline() +... .add("bb", BBANDS, output_keys=["bb_upper", "bb_mid", "bb_lower"], +... timeperiod=5, nbdevup=2.0, nbdevdn=2.0) +... .add("macd", MACD, output_keys=["macd", "signal", "hist"], +... fastperiod=3, slowperiod=5, signalperiod=2) +... ) +>>> results = pipe.run(close) +>>> list(results.keys()) +['bb_upper', 'bb_mid', 'bb_lower', 'macd', 'signal', 'hist'] +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._utils import _to_f64 + +# --------------------------------------------------------------------------- +# Internal step type +# --------------------------------------------------------------------------- + + +class _Step: + """A single pipeline step (one indicator call).""" + + __slots__ = ("name", "func", "kwargs", "output_keys") + + def __init__( + self, + name: str, + func: Callable[..., Any], + kwargs: dict[str, Any], + output_keys: Optional[list[str]], + ) -> None: + self.name = name + self.func = func + self.kwargs = kwargs + self.output_keys = output_keys + + +# --------------------------------------------------------------------------- +# Pipeline +# --------------------------------------------------------------------------- + + +class Pipeline: + """A reusable indicator pipeline. + + A Pipeline stores a sequence of named indicator steps and can be applied + to one or more data arrays. Calling :meth:`run` returns a dictionary + mapping step names to result arrays. + + Parameters + ---------- + steps : list of (name, func, kwargs, output_keys), optional + Pre-built steps (rarely needed; prefer :meth:`add`). + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA, RSI + >>> from ferro_ta.tools.pipeline import Pipeline + >>> close = np.arange(1.0, 20.0) + >>> results = Pipeline().add("sma5", SMA, timeperiod=5).run(close) + >>> results["sma5"].shape + (19,) + """ + + def __init__(self, steps: Optional[list[_Step]] = None) -> None: + self._steps: list[_Step] = list(steps) if steps else [] + + # ------------------------------------------------------------------ + # Step management + # ------------------------------------------------------------------ + + def add( + self, + name: str, + func: Callable[..., Any], + output_keys: Optional[list[str]] = None, + **kwargs: Any, + ) -> Pipeline: + """Add an indicator step to the pipeline. + + Parameters + ---------- + name : str + Key under which the result is stored in the output dict. + For multi-output indicators with *output_keys*, this argument + is ignored (the output_keys are used instead). + func : callable + Indicator function (e.g. ``SMA``, ``RSI``, ``BBANDS``). + output_keys : list of str, optional + For multi-output indicators that return a tuple (e.g. BBANDS, + MACD), supply the names for each output. If not provided and + the indicator returns a tuple, the results are stored as + ``name_0``, ``name_1``, … . + **kwargs + Keyword arguments forwarded to *func* (e.g. ``timeperiod=14``). + + Returns + ------- + Pipeline + Returns ``self`` for chaining. + + Raises + ------ + ValueError + If *name* is already used by an existing step (and no + *output_keys* are supplied). + TypeError + If *func* is not callable. + """ + if not callable(func): + raise TypeError(f"func must be callable, got {type(func).__name__}") + + # Check for duplicate names (only when output_keys is not given) + existing = self._output_names() + if output_keys: + for key in output_keys: + if key in existing: + raise ValueError(f"Duplicate output key '{key}' in pipeline") + else: + if name in existing: + raise ValueError( + f"A step named '{name}' already exists. " + "Use a different name or remove the existing step first." + ) + + self._steps.append(_Step(name, func, kwargs, output_keys)) + return self + + def remove(self, name: str) -> Pipeline: + """Remove the step identified by *name* (or *output_keys* containing *name*). + + Parameters + ---------- + name : str + Step name or one of the output keys. + + Returns + ------- + Pipeline + Returns ``self`` for chaining. + + Raises + ------ + KeyError + If no step with the given name is found. + """ + for i, step in enumerate(self._steps): + if step.name == name or (step.output_keys and name in step.output_keys): + del self._steps[i] + return self + raise KeyError(f"No step named '{name}' in pipeline") + + def steps(self) -> list[str]: + """Return a list of step names (or output keys for multi-output steps).""" + return self._output_names() + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def run(self, close: ArrayLike, **extra: Any) -> dict[str, np.ndarray]: + """Apply all pipeline steps to *close* and return results. + + Parameters + ---------- + close : array-like + Primary input array (close prices). For indicators that need + additional arrays (e.g. high/low/volume), pass them as keyword + arguments (see *extra*). + **extra + Additional arrays (e.g. ``high=…``, ``low=…``, ``volume=…``). + Each step's kwargs are merged with *extra* on a per-call basis; + step-level kwargs take precedence. + + Returns + ------- + dict of str → numpy.ndarray + Mapping from output name to result array. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA, ATR + >>> from ferro_ta.tools.pipeline import Pipeline + >>> n = 20 + >>> close = np.random.rand(n) + 10 + >>> high = close + 0.5 + >>> low = close - 0.5 + >>> pipe = ( + ... Pipeline() + ... .add("sma", SMA, timeperiod=5) + ... ) + >>> out = pipe.run(close) + >>> out["sma"].shape + (20,) + """ + close_arr = _to_f64(close) + output: dict[str, np.ndarray] = {} + + for step in self._steps: + # Build merged kwargs: extra is the base; step-level kwargs override + merged = dict(extra) + merged.update(step.kwargs) + + result = step.func(close_arr, **merged) + + if isinstance(result, tuple): + if step.output_keys: + if len(step.output_keys) != len(result): + raise ValueError( + f"Step '{step.name}': output_keys has {len(step.output_keys)} " + f"entries but the function returned {len(result)} values." + ) + for key, arr in zip(step.output_keys, result): + output[key] = np.asarray(arr, dtype=np.float64) + else: + for i, arr in enumerate(result): + output[f"{step.name}_{i}"] = np.asarray(arr, dtype=np.float64) + else: + output[step.name] = np.asarray(result, dtype=np.float64) + + return output + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _output_names(self) -> list[str]: + names: list[str] = [] + for step in self._steps: + if step.output_keys: + names.extend(step.output_keys) + else: + names.append(step.name) + return names + + def __len__(self) -> int: + return len(self._steps) + + def __repr__(self) -> str: + step_str = ", ".join(self._output_names()) + return f"Pipeline([{step_str}])" + + +# --------------------------------------------------------------------------- +# Convenience factory +# --------------------------------------------------------------------------- + + +def make_pipeline(**named_steps: tuple[Callable[..., Any], dict[str, Any]]) -> Pipeline: + """Build a :class:`Pipeline` from keyword arguments. + + Parameters + ---------- + **named_steps + Each keyword argument is a step: ``name=(func, kwargs_dict)``. + + Returns + ------- + Pipeline + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA, RSI + >>> from ferro_ta.tools.pipeline import make_pipeline + >>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}), + ... rsi_14=(RSI, {"timeperiod": 14})) + >>> results = pipe.run(np.arange(1.0, 25.0)) + >>> sorted(results.keys()) + ['rsi_14', 'sma_5'] + """ + pipe = Pipeline() + for name, step in named_steps.items(): + func, kwargs = step + pipe.add(name, func, **kwargs) + return pipe + + +__all__ = [ + "Pipeline", + "make_pipeline", +] diff --git a/ferro-ta-main/python/ferro_ta/tools/tools.py b/ferro-ta-main/python/ferro_ta/tools/tools.py new file mode 100644 index 0000000..4a5e61f --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/tools.py @@ -0,0 +1,284 @@ +""" +ferro_ta.tools — Stable Tool Wrappers for Agent / LLM Integration +================================================================= + +Provides stable, well-documented functions that are easy to wrap as +LangChain/LlamaIndex/OpenAI Function tools or to call from automated agents. + +All functions have clear signatures, descriptive docstrings, and return +JSON-serializable types so that agent frameworks can inspect and call them +without special handling. + +See ``docs/agentic.md`` for the full agentic workflow guide, LangChain +integration examples, and scheduling instructions. + +Quick start +----------- +>>> import numpy as np +>>> from ferro_ta.tools import compute_indicator, run_backtest, list_indicators +>>> +>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 +>>> +>>> # Compute a single indicator by name +>>> result = compute_indicator("SMA", close, timeperiod=14) +>>> +>>> # Run a backtest +>>> summary = run_backtest("rsi_30_70", close) +>>> print(summary["final_equity"]) + +API +--- +compute_indicator(name, *args, **kwargs) → array or dict + Compute a built-in or registered indicator by name. + +run_backtest(strategy, close, **kwargs) → dict + Run a backtest and return a summary dict. + +list_indicators() → list[str] + list all registered indicator names. + +describe_indicator(name) → str + Return the docstring of a registered indicator (or a summary). +""" + +from __future__ import annotations + +from typing import Any, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = [ + "compute_indicator", + "run_backtest", + "list_indicators", + "describe_indicator", +] + + +def compute_indicator( + name: str, + *args: ArrayLike, + **kwargs: Any, +) -> Union[NDArray[np.float64], dict[str, NDArray[np.float64]]]: + """Compute a named indicator and return the result. + + Delegates to the ferro_ta registry so that both built-in and custom + indicators can be called by name. + + Parameters + ---------- + name : str + Indicator name (e.g. ``"SMA"``, ``"RSI"``, ``"BBANDS"``). + Case-sensitive; use :func:`list_indicators` to see all names. + *args : array-like + Positional data arrays forwarded to the indicator (e.g. close, high). + **kwargs + Parameter keyword arguments forwarded to the indicator + (e.g. ``timeperiod=14``). + + Returns + ------- + ndarray or dict of str → ndarray + For single-output indicators, returns a 1-D ``numpy.ndarray``. + For multi-output indicators (e.g. BBANDS, MACD), returns a dict + mapping output names to arrays. The dict keys follow TA-Lib + conventions where known (``"upper"``/``"middle"``/``"lower"`` for + BBANDS; ``"macd"``/``"signal"``/``"hist"`` for MACD; etc.). + + Raises + ------ + ferro_ta.registry.FerroTARegistryError + If *name* is not a known indicator. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools import compute_indicator + >>> close = np.linspace(100, 110, 20) + >>> result = compute_indicator("SMA", close, timeperiod=5) + >>> result.shape + (20,) + >>> bb = compute_indicator("BBANDS", close, timeperiod=5) + >>> sorted(bb.keys()) + ['lower', 'middle', 'upper'] + """ + from ferro_ta.core.registry import run as _registry_run + + raw = _registry_run(name, *args, **kwargs) + + if isinstance(raw, tuple): + # Multi-output: try to map to named keys for well-known indicators + _multi_keys: dict[str, list[str]] = { + "BBANDS": ["upper", "middle", "lower"], + "MACD": ["macd", "signal", "hist"], + "MACDEXT": ["macd", "signal", "hist"], + "MACDFIX": ["macd", "signal", "hist"], + "STOCH": ["slowk", "slowd"], + "STOCHF": ["fastk", "fastd"], + "STOCHRSI": ["fastk", "fastd"], + "AROON": ["aroondown", "aroonup"], + "HT_PHASOR": ["inphase", "quadrature"], + "HT_SINE": ["sine", "leadsine"], + "MAMA": ["mama", "fama"], + } + keys = _multi_keys.get(name.upper()) + if keys and len(keys) == len(raw): + return {k: np.asarray(v, dtype=np.float64) for k, v in zip(keys, raw)} + # Fallback: use integer keys + return {str(i): np.asarray(v, dtype=np.float64) for i, v in enumerate(raw)} + + return np.asarray(raw, dtype=np.float64) + + +def run_backtest( + strategy: str, + close: ArrayLike, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + **strategy_kwargs: Any, +) -> dict[str, Any]: + """Run a named backtest strategy and return a summary dictionary. + + This is a convenience wrapper around :func:`ferro_ta.backtest.backtest` + that returns a JSON-serializable summary dict rather than a + ``BacktestResult`` object, making it easy to use from agent tools. + + Parameters + ---------- + strategy : str + Name of the built-in strategy: ``"rsi_30_70"``, ``"sma_crossover"``, + or ``"macd_crossover"``. + close : array-like + Close prices (1-D, at least 2 bars). + commission_per_trade : float + Fixed commission deducted from equity on each position change. + slippage_bps : float + Slippage in basis points applied on position-change bars. + **strategy_kwargs + Extra kwargs forwarded to the strategy function + (e.g. ``timeperiod=14``, ``oversold=25``). + + Returns + ------- + dict + Summary with the following keys: + + * ``"strategy"`` — the strategy name used. + * ``"n_bars"`` — number of price bars. + * ``"n_trades"`` — number of position changes. + * ``"final_equity"`` — terminal equity value (start = 1.0). + * ``"max_drawdown"`` — maximum drawdown fraction (0–1, positive value + represents the magnitude of loss). + * ``"equity"`` — equity curve as a Python list of floats. + * ``"signals"`` — signal array as a Python list. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools import run_backtest + >>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 + >>> summary = run_backtest("rsi_30_70", close) + >>> isinstance(summary["final_equity"], float) + True + """ + from ferro_ta.analysis.backtest import backtest as _backtest + + result = _backtest( + close, + strategy=strategy, + commission_per_trade=commission_per_trade, + slippage_bps=slippage_bps, + **strategy_kwargs, + ) + + equity = np.asarray(result.equity, dtype=np.float64) + # Compute max drawdown + running_max = np.maximum.accumulate(equity) + drawdowns = (running_max - equity) / np.where(running_max > 0, running_max, 1.0) + max_dd = float(np.nanmax(drawdowns)) if len(drawdowns) > 0 else 0.0 + + return { + "strategy": strategy, + "n_bars": len(result.signals), + "n_trades": result.n_trades, + "final_equity": result.final_equity, + "max_drawdown": max_dd, + "equity": equity.tolist(), + "signals": np.asarray(result.signals, dtype=np.float64).tolist(), + } + + +def list_indicators() -> list[str]: + """Return a sorted list of all registered indicator names. + + Includes both built-in ferro_ta indicators and any custom indicators + registered via :func:`ferro_ta.registry.register`. + + Returns + ------- + list of str + Sorted list of indicator names (e.g. ``["AD", "ADOSC", "ADX", …]``). + + Examples + -------- + >>> from ferro_ta.tools import list_indicators + >>> names = list_indicators() + >>> "SMA" in names + True + >>> "RSI" in names + True + """ + from ferro_ta.core.registry import list_indicators as _list + + return _list() + + +def describe_indicator(name: str) -> str: + """Return a human-readable description of a registered indicator. + + Looks up the indicator's docstring and returns the first paragraph (up to + the first blank line) so it can be used in agent prompts or tool + descriptions. + + Parameters + ---------- + name : str + Indicator name (case-sensitive). Use :func:`list_indicators` to get + valid names. + + Returns + ------- + str + The first paragraph of the indicator's docstring, or a fallback + message if no docstring is available. + + Raises + ------ + ferro_ta.registry.FerroTARegistryError + If *name* is not a known indicator. + + Examples + -------- + >>> from ferro_ta.tools import describe_indicator + >>> desc = describe_indicator("SMA") + >>> isinstance(desc, str) and len(desc) > 0 + True + """ + from ferro_ta.core.registry import get as _get + + func = _get(name) + doc = getattr(func, "__doc__", None) or "" + if not doc.strip(): + return f"{name}: no description available." + + # Return only the first paragraph (before the first blank line) + lines = doc.strip().splitlines() + para: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped == "" and para: + break + para.append(stripped) + + return " ".join(para).strip() or f"{name}: no description available." diff --git a/ferro-ta-main/python/ferro_ta/tools/viz.py b/ferro-ta-main/python/ferro_ta/tools/viz.py new file mode 100644 index 0000000..20e40ba --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/viz.py @@ -0,0 +1,351 @@ +""" +ferro_ta.viz — Charting and visualisation API. + +Generates charts (matplotlib and/or Plotly) with indicators overlaid on price. + +API +--- +plot(ohlcv, indicators=None, *, backend='matplotlib', title=None, + figsize=None, savefig=None, show=False) + Generate a chart from OHLCV data and optional indicator series. + Returns a figure object for further customisation. + +Backends +-------- +- ``'matplotlib'`` — requires ``matplotlib`` (recommended for static charts) +- ``'plotly'`` — requires ``plotly`` (recommended for interactive charts) + +Install optional backends:: + + pip install ferro-ta[plot] # adds matplotlib + plotly + pip install matplotlib # matplotlib only + pip install plotly # plotly only + +Examples +-------- +>>> import numpy as np +>>> from ferro_ta import RSI, SMA +>>> from ferro_ta.tools.viz import plot +>>> rng = np.random.default_rng(0) +>>> n = 60 +>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100 +>>> ohlcv = {"close": close, "open": close, "high": close * 1.01, +... "low": close * 0.99, "volume": np.ones(n) * 1000} +>>> fig = plot(ohlcv, indicators={"RSI(14)": RSI(close, timeperiod=14), +... "SMA(20)": SMA(close, timeperiod=20)}, +... backend='matplotlib', show=False) +>>> fig is not None +True +""" + +from __future__ import annotations + +import warnings +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = [ + "plot", +] + +# --------------------------------------------------------------------------- +# plot +# --------------------------------------------------------------------------- + + +def plot( + ohlcv: Any, + indicators: Optional[dict[str, ArrayLike]] = None, + *, + backend: str = "matplotlib", + title: Optional[str] = None, + figsize: Optional[tuple[float, float]] = None, + savefig: Optional[str] = None, + show: bool = True, + volume: bool = True, + close_col: str = "close", + volume_col: str = "volume", +) -> Any: + """Generate a chart from OHLCV data and optional indicator series. + + Parameters + ---------- + ohlcv : dict, pandas.DataFrame, or array-like + OHLCV data. At minimum a ``close`` key/column is required. + indicators : dict {label: array}, optional + Additional indicator series to plot below the price panel. + Each entry is plotted in its own subplot. + backend : str + ``'matplotlib'`` (default) or ``'plotly'``. + title : str, optional + Chart title. + figsize : (width, height), optional + Figure size in inches (matplotlib) or pixels (plotly). + savefig : str, optional + Save figure to this file path (e.g. ``'chart.png'``, ``'chart.html'``). + show : bool + If ``True``, call ``plt.show()`` or ``fig.show()`` interactively. + volume : bool + If ``True`` and a volume series is present, add a volume subplot. + close_col, volume_col : str + Column names when *ohlcv* is a DataFrame. + + Returns + ------- + matplotlib.figure.Figure or plotly.graph_objects.Figure + + Raises + ------ + ImportError + If the requested backend is not installed. + """ + close_arr, volume_arr = _extract_close_volume(ohlcv, close_col, volume_col) + + if backend == "matplotlib": + return _plot_matplotlib( + close_arr, + volume_arr if volume else None, + indicators, + title=title, + figsize=figsize, + savefig=savefig, + show=show, + ) + elif backend == "plotly": + return _plot_plotly( + close_arr, + volume_arr if volume else None, + indicators, + title=title, + figsize=figsize, + savefig=savefig, + show=show, + ) + else: + raise ValueError( + f"Unknown backend {backend!r}. Supported: 'matplotlib', 'plotly'." + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _extract_close_volume( + ohlcv: Any, + close_col: str, + volume_col: str, +) -> tuple[NDArray[np.float64], Optional[NDArray[np.float64]]]: + """Extract close and (optional) volume from various input formats.""" + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame): + close = ohlcv[close_col].values.astype(np.float64) + volume = ( + ohlcv[volume_col].values.astype(np.float64) + if volume_col in ohlcv.columns + else None + ) + return close, volume + except ImportError: + pass + + if isinstance(ohlcv, dict): + close = np.asarray( + ohlcv.get(close_col, ohlcv.get("close", [])), dtype=np.float64 + ) + vol_key = volume_col if volume_col in ohlcv else "volume" + volume = ( + np.asarray(ohlcv[vol_key], dtype=np.float64) if vol_key in ohlcv else None + ) + return close, volume + + # Plain array + return np.asarray(ohlcv, dtype=np.float64), None + + +def _n_subplots(indicators: Optional[dict], volume_arr: Optional[NDArray]) -> int: + n = 1 # price + if volume_arr is not None: + n += 1 + if indicators: + n += len(indicators) + return n + + +# --------------------------------------------------------------------------- +# Matplotlib backend +# --------------------------------------------------------------------------- + + +def _plot_matplotlib( + close: NDArray, + volume: Optional[NDArray], + indicators: Optional[dict[str, ArrayLike]], + *, + title: Optional[str], + figsize: Optional[tuple], + savefig: Optional[str], + show: bool, +) -> Any: + try: + import matplotlib.gridspec as gridspec + import matplotlib.pyplot as plt + except ImportError as exc: + raise ImportError( + "matplotlib is required for the 'matplotlib' backend. " + "Install with: pip install matplotlib" + ) from exc + + n_subplots = _n_subplots(indicators, volume) + height_ratios = [3] + [1] * (n_subplots - 1) + fig_h = figsize[1] if figsize else 2.5 * n_subplots + 1 + fig_w = figsize[0] if figsize else 12.0 + fig = plt.figure(figsize=(fig_w, fig_h)) + gs = gridspec.GridSpec(n_subplots, 1, height_ratios=height_ratios, hspace=0.35) + + ax_price = fig.add_subplot(gs[0]) + ax_price.plot(close, color="#1f77b4", linewidth=1.2, label="close") + ax_price.set_ylabel("Price") + ax_price.legend(loc="upper left", fontsize=8) + ax_price.grid(alpha=0.3) + if title: + ax_price.set_title(title) + + row = 1 + if volume is not None: + ax_vol = fig.add_subplot(gs[row], sharex=ax_price) + ax_vol.bar(range(len(volume)), volume, color="#aec7e8", alpha=0.7, width=0.8) + ax_vol.set_ylabel("Volume") + ax_vol.grid(alpha=0.3) + row += 1 + + if indicators: + colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"] + for idx, (label, arr) in enumerate(indicators.items()): + ax_ind = fig.add_subplot(gs[row], sharex=ax_price) + color = colors[idx % len(colors)] + arr_np = np.asarray(arr, dtype=np.float64) + ax_ind.plot(arr_np, color=color, linewidth=1.0, label=label) + ax_ind.set_ylabel(label, fontsize=8) + ax_ind.legend(loc="upper left", fontsize=8) + ax_ind.grid(alpha=0.3) + row += 1 + + # Use tight_layout when possible but suppress known benign UserWarning + # about incompatible Axes configurations. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="This figure includes Axes that are not compatible with tight_layout.*", + category=UserWarning, + ) + plt.tight_layout() + + if savefig: + fig.savefig(savefig, dpi=100, bbox_inches="tight") + if show: + plt.show() + return fig + + +# --------------------------------------------------------------------------- +# Plotly backend +# --------------------------------------------------------------------------- + + +def _plot_plotly( + close: NDArray, + volume: Optional[NDArray], + indicators: Optional[dict[str, ArrayLike]], + *, + title: Optional[str], + figsize: Optional[tuple], + savefig: Optional[str], + show: bool, +) -> Any: + try: + import plotly.graph_objects as go + from plotly.subplots import make_subplots + except ImportError as exc: + raise ImportError( + "plotly is required for the 'plotly' backend. " + "Install with: pip install plotly" + ) from exc + + n_subplots = _n_subplots(indicators, volume) + row_heights = [0.5] + [0.1] * (n_subplots - 1) + total = sum(row_heights) + row_heights = [r / total for r in row_heights] + shared_xaxes = True + subplot_titles = ["Price"] + if volume is not None: + subplot_titles.append("Volume") + if indicators: + subplot_titles.extend(list(indicators.keys())) + + fig = make_subplots( + rows=n_subplots, + cols=1, + shared_xaxes=shared_xaxes, + row_heights=row_heights, + subplot_titles=subplot_titles, + vertical_spacing=0.05, + ) + x = list(range(len(close))) + fig.add_trace( + go.Scatter( + x=x, y=close.tolist(), mode="lines", name="close", line={"color": "#1f77b4"} + ), + row=1, + col=1, + ) + + row = 2 + if volume is not None: + fig.add_trace( + go.Bar(x=x, y=volume.tolist(), name="volume", marker_color="#aec7e8"), + row=row, + col=1, + ) + row += 1 + + if indicators: + colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"] + for idx, (label, arr) in enumerate(indicators.items()): + arr_np = np.asarray(arr, dtype=np.float64) + color = colors[idx % len(colors)] + fig.add_trace( + go.Scatter( + x=x, + y=arr_np.tolist(), + mode="lines", + name=label, + line={"color": color}, + ), + row=row, + col=1, + ) + row += 1 + + fig_w = figsize[0] if figsize else 900 + fig_h = figsize[1] if figsize else 500 + fig.update_layout( + title=title or "ferro_ta Chart", + width=fig_w, + height=fig_h, + showlegend=True, + ) + + if savefig: + if savefig.endswith(".html"): + fig.write_html(savefig) + else: + fig.write_image(savefig) + if show: + fig.show() + return fig diff --git a/ferro-ta-main/python/ferro_ta/tools/workflow.py b/ferro-ta-main/python/ferro_ta/tools/workflow.py new file mode 100644 index 0000000..a3d314f --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/tools/workflow.py @@ -0,0 +1,333 @@ +""" +ferro_ta.workflow — End-to-End Workflow Orchestration +===================================================== + +Provides a lightweight DAG/linear workflow that chains data acquisition, +resampling, indicator computation, strategy signal generation, and alerting +in a single call. All heavy computation is delegated to existing ferro_ta +modules; this module is **pure orchestration** with no new algorithmic logic. + +See ``docs/agentic.md`` for a full end-to-end example including LangChain +integration and scheduling. + +Quick start +----------- +>>> import numpy as np +>>> from ferro_ta.tools.workflow import Workflow +>>> +>>> # Build a workflow +>>> wf = ( +... Workflow() +... .add_indicator("sma_20", "SMA", timeperiod=20) +... .add_indicator("rsi_14", "RSI", timeperiod=14) +... .add_strategy("rsi_30_70") +... ) +>>> +>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 +>>> result = wf.run(close) +>>> print(result.keys()) + +API +--- +Workflow + Fluent builder that chains: indicators → strategy → backtest → alerts. + +run_pipeline(close, indicators, strategy, alert_level) + Functional interface: single call that returns all outputs. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike + +__all__ = [ + "Workflow", + "run_pipeline", +] + + +class Workflow: + """Fluent builder for an end-to-end ferro_ta workflow. + + A :class:`Workflow` chains these optional steps in order: + + 1. **Indicators** — compute one or more named indicators on close prices. + 2. **Strategy** — optionally run a backtest strategy and capture the result. + 3. **Alerts** — optionally define threshold or cross alerts on any indicator + output and collect firing bars. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.workflow import Workflow + >>> rng = np.random.default_rng(42) + >>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100 + >>> result = ( + ... Workflow() + ... .add_indicator("sma_20", "SMA", timeperiod=20) + ... .add_indicator("rsi_14", "RSI", timeperiod=14) + ... .run(close) + ... ) + >>> "sma_20" in result + True + >>> "rsi_14" in result + True + """ + + def __init__(self) -> None: + self._indicator_steps: list[tuple[str, str, dict[str, Any]]] = [] + self._strategy: Optional[str] = None + self._strategy_kwargs: dict[str, Any] = {} + self._alert_steps: list[tuple[str, str, float, int]] = [] + + # ------------------------------------------------------------------ + # Fluent builders + # ------------------------------------------------------------------ + + def add_indicator( + self, + output_key: str, + indicator_name: str, + **kwargs: Any, + ) -> Workflow: + """Add an indicator step. + + Parameters + ---------- + output_key : str + Key under which the result will be stored in the output dict. + indicator_name : str + Name of the indicator (e.g. ``"SMA"``, ``"RSI"``). + **kwargs + Parameters forwarded to the indicator (e.g. ``timeperiod=14``). + + Returns + ------- + Workflow + Self, for chaining. + """ + self._indicator_steps.append((output_key, indicator_name, kwargs)) + return self + + def add_strategy( + self, + strategy: str, + **strategy_kwargs: Any, + ) -> Workflow: + """Set the backtest strategy to run. + + Only one strategy can be active at a time; calling this method again + replaces the previous strategy. + + Parameters + ---------- + strategy : str + Strategy name (``"rsi_30_70"``, ``"sma_crossover"``, or + ``"macd_crossover"``). + **strategy_kwargs + Extra parameters forwarded to the strategy function. + + Returns + ------- + Workflow + Self, for chaining. + """ + self._strategy = strategy + self._strategy_kwargs = dict(strategy_kwargs) + return self + + def add_alert( + self, + indicator_key: str, + level: float, + direction: int = 1, + ) -> Workflow: + """Add a threshold crossing alert on an indicator output. + + The alert fires on bars where the specified indicator crosses *level* + in *direction*. + + Parameters + ---------- + indicator_key : str + Key of an indicator already added via :meth:`add_indicator`. + level : float + Alert level (e.g. 30 for RSI oversold). + direction : int + ``+1`` → alert when series crosses *above* level. + ``-1`` → alert when series crosses *below* level. + + Returns + ------- + Workflow + Self, for chaining. + """ + alert_key = f"alert_{indicator_key}_{level:.4g}_{direction:+d}" + self._alert_steps.append((alert_key, indicator_key, level, direction)) + return self + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def run( + self, + close: ArrayLike, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + ) -> dict[str, Any]: + """Execute the workflow and return all outputs. + + Parameters + ---------- + close : array-like + Close price series (1-D). + commission_per_trade : float + Commission forwarded to backtest (if strategy is set). + slippage_bps : float + Slippage in bps forwarded to backtest (if strategy is set). + + Returns + ------- + dict + Dictionary containing: + + * Each indicator key → ``numpy.ndarray`` result (or dict for + multi-output indicators such as BBANDS/MACD). + * ``"backtest"`` → summary dict (only if a strategy was added). + * Each alert key → list of bar indices where alert fired + (only if alerts were added). + """ + from ferro_ta.tools import compute_indicator, run_backtest + + close_arr = np.asarray(close, dtype=np.float64) + output: dict[str, Any] = {} + + # Step 1: compute indicators + for output_key, indicator_name, kwargs in self._indicator_steps: + output[output_key] = compute_indicator(indicator_name, close_arr, **kwargs) + + # Step 2: run backtest strategy (if set) + if self._strategy is not None: + output["backtest"] = run_backtest( + self._strategy, + close_arr, + commission_per_trade=commission_per_trade, + slippage_bps=slippage_bps, + **self._strategy_kwargs, + ) + + # Step 3: compute alerts + if self._alert_steps: + from ferro_ta.tools.alerts import check_threshold, collect_alert_bars + + for alert_key, ind_key, level, direction in self._alert_steps: + series = output.get(ind_key) + if series is None: + continue + # For multi-output indicators, skip alert silently + if isinstance(series, dict): + continue + arr = np.asarray(series, dtype=np.float64) + mask = check_threshold(arr, level=level, direction=direction) + output[alert_key] = collect_alert_bars(mask).tolist() + + return output + + +# --------------------------------------------------------------------------- +# Functional interface +# --------------------------------------------------------------------------- + + +def run_pipeline( + close: ArrayLike, + indicators: Optional[dict[str, dict[str, Any]]] = None, + strategy: Optional[str] = None, + strategy_kwargs: Optional[dict[str, Any]] = None, + alert_level: Optional[float] = None, + alert_indicator: Optional[str] = None, + alert_direction: int = -1, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, +) -> dict[str, Any]: + """Run a full ferro_ta pipeline in one call. + + Functional wrapper around :class:`Workflow` for scripting and agent use. + + Parameters + ---------- + close : array-like + Close price series. + indicators : dict of {str: dict}, optional + Mapping of ``output_key → kwargs_dict`` for indicators to compute. + The indicator name must be embedded as ``"name"`` in the kwargs dict. + + Example:: + + indicators = { + "sma_20": {"name": "SMA", "timeperiod": 20}, + "rsi_14": {"name": "RSI", "timeperiod": 14}, + } + + strategy : str, optional + Built-in strategy name (``"rsi_30_70"`` etc.). + strategy_kwargs : dict, optional + Extra kwargs for the strategy. + alert_level : float, optional + If set, add a threshold alert on *alert_indicator* at this level. + alert_indicator : str, optional + Key of the indicator to alert on (must be in *indicators*). + alert_direction : int + Direction of the alert: ``+1`` cross-above, ``-1`` cross-below. + commission_per_trade : float + Backtest commission. + slippage_bps : float + Backtest slippage in bps. + + Returns + ------- + dict + Same structure as :meth:`Workflow.run`. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.workflow import run_pipeline + >>> rng = np.random.default_rng(0) + >>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100 + >>> result = run_pipeline( + ... close, + ... indicators={ + ... "sma_20": {"name": "SMA", "timeperiod": 20}, + ... "rsi_14": {"name": "RSI", "timeperiod": 14}, + ... }, + ... strategy="rsi_30_70", + ... ) + >>> "sma_20" in result + True + >>> "backtest" in result + True + """ + wf = Workflow() + + if indicators: + for key, params in indicators.items(): + params = dict(params) + ind_name = params.pop("name") + wf.add_indicator(key, ind_name, **params) + + if strategy: + wf.add_strategy(strategy, **(strategy_kwargs or {})) + + if alert_level is not None and alert_indicator is not None: + wf.add_alert(alert_indicator, level=alert_level, direction=alert_direction) + + return wf.run( + close, + commission_per_trade=commission_per_trade, + slippage_bps=slippage_bps, + ) diff --git a/ferro-ta-main/python/ferro_ta/utils.py b/ferro-ta-main/python/ferro_ta/utils.py new file mode 100644 index 0000000..e94f869 --- /dev/null +++ b/ferro-ta-main/python/ferro_ta/utils.py @@ -0,0 +1,9 @@ +""" +Public utilities for ferro_ta (Pandas DataFrame OHLCV contract, etc.). +""" + +from __future__ import annotations + +from ferro_ta._utils import get_ohlcv + +__all__ = ["get_ohlcv"] diff --git a/ferro-ta-main/scripts/build_api_manifest.py b/ferro-ta-main/scripts/build_api_manifest.py new file mode 100644 index 0000000..9f92a3e --- /dev/null +++ b/ferro-ta-main/scripts/build_api_manifest.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Build a cross-surface API manifest for ferro-ta. + +The generated manifest summarizes: +- Python indicator/method exposure (from ferro_ta.tools.api_info) +- Core Rust crate public functions (ferro_ta_core) +- WASM/Node exported functions (from wasm pkg d.ts) + +Output is written to `docs/api_manifest.json`. +""" + +from __future__ import annotations + +import argparse +import ast +import datetime as _dt +import importlib.util +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _load_api_info_module(root: Path, module_path: Path): + python_root = str(root / "python") + if python_root not in sys.path: + sys.path.insert(0, python_root) + spec = importlib.util.spec_from_file_location( + "ferro_ta_tools_api_info", module_path + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load module spec from {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[assignment] + return module + + +def _module_file(root: Path, module_name: str) -> Path | None: + module_rel = module_name.replace(".", "/") + file_path = root / "python" / f"{module_rel}.py" + if file_path.exists(): + return file_path + init_path = root / "python" / module_rel / "__init__.py" + if init_path.exists(): + return init_path + return None + + +def _extract_dunder_all(file_path: Path) -> list[str]: + try: + source = file_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(file_path)) + except Exception: + return [] + + exports: list[str] = [] + for node in tree.body: + value_node = None + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + value_node = node.value + break + elif isinstance(node, ast.AnnAssign): + target = node.target + if isinstance(target, ast.Name) and target.id == "__all__": + value_node = node.value + if value_node is None: + continue + try: + value = ast.literal_eval(value_node) + except Exception: + continue + if isinstance(value, str): + exports = [value] + elif isinstance(value, (list, tuple)): + exports = [item for item in value if isinstance(item, str)] + return exports + + +def _module_exports(root: Path, module_name: str) -> list[str]: + file_path = _module_file(root, module_name) + if file_path is None: + return [] + return _extract_dunder_all(file_path) + + +def _extract_python_api(root: Path) -> dict[str, Any]: + module_path = root / "python" / "ferro_ta" / "tools" / "api_info.py" + api_info_module = _load_api_info_module(root, module_path) + + category_modules = dict(getattr(api_info_module, "_CATEGORY_MODULES", {})) + method_modules = dict(getattr(api_info_module, "_METHOD_MODULES", {})) + + indicators: list[dict[str, Any]] = [] + seen_indicators: set[str] = set() + for category, module_name in category_modules.items(): + for name in _module_exports(root, module_name): + if name in seen_indicators: + continue + seen_indicators.add(name) + indicators.append( + { + "name": name, + "category": category, + "module": module_name, + "doc": "", + "params": [], + } + ) + + methods: list[dict[str, Any]] = [] + seen_methods: set[tuple[str, str]] = set() + for category, module_name in method_modules.items(): + for name in _module_exports(root, module_name): + key = (module_name, name) + if key in seen_methods: + continue + seen_methods.add(key) + methods.append( + { + "name": name, + "category": category, + "module": module_name, + "doc": "", + "params": [], + } + ) + + indicators.sort(key=lambda entry: entry["name"]) + methods.sort(key=lambda entry: (entry["category"], entry["name"])) + + categories = sorted({entry["category"] for entry in indicators}) + + if not indicators: + raise RuntimeError( + "No Python indicators discovered from source exports. " + "Check `python/ferro_ta/tools/api_info.py` mappings and module __all__ declarations." + ) + + return { + "indicator_count": len(indicators), + "method_count": len(methods), + "categories": categories, + "indicators": indicators, + "methods": methods, + } + + +def _extract_core_exports(root: Path) -> list[dict[str, str]]: + core_src = root / "crates" / "ferro_ta_core" / "src" + entries: list[dict[str, str]] = [] + + for rs_file in sorted(core_src.rglob("*.rs")): + rel = rs_file.relative_to(core_src).as_posix() + module = rel[:-3].replace("/", ".") + text = rs_file.read_text(encoding="utf-8") + for match in re.finditer(r"(?m)^\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(", text): + entries.append( + { + "module": module, + "function": match.group(1), + "file": rel, + } + ) + + entries.sort(key=lambda item: (item["module"], item["function"])) + return entries + + +def _extract_wasm_exports(root: Path) -> list[str]: + exports: set[str] = set() + + # Source exports are the canonical declaration of the WASM/Node API and + # avoid drift when a stale wasm/pkg folder is present locally. + wasm_lib = root / "wasm" / "src" / "lib.rs" + if wasm_lib.exists(): + text = wasm_lib.read_text(encoding="utf-8") + for match in re.finditer( + r"(?ms)#\s*\[wasm_bindgen(?:\([^\)]*\))?\]\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(", + text, + ): + exports.add(match.group(1)) + if exports: + return sorted(exports) + + # Fallback to generated declarations if source parsing did not find exports. + dts_path = root / "wasm" / "node" / "ferro_ta_wasm.d.ts" + if dts_path.exists(): + for line in dts_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("export function "): + name = line[len("export function ") :].split("(")[0].strip() + if name: + exports.add(name) + + return sorted(exports) + + +def _safe_git_head(root: Path) -> str | None: + try: + completed = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=root, + capture_output=True, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + value = completed.stdout.strip() + return value or None + + +def build_manifest( + root: Path, include_runtime_metadata: bool = False +) -> dict[str, Any]: + python_api = _extract_python_api(root) + rust_core = _extract_core_exports(root) + wasm_exports = _extract_wasm_exports(root) + + python_indicator_names = {entry["name"] for entry in python_api["indicators"]} + python_indicator_names_lc = {name.lower() for name in python_indicator_names} + wasm_set = set(wasm_exports) + wasm_set_lc = {name.lower() for name in wasm_set} + common_with_wasm = sorted(python_indicator_names_lc.intersection(wasm_set_lc)) + + manifest: dict[str, Any] = { + "surfaces": { + "python": python_api, + "rust_core": { + "public_function_count": len(rust_core), + "functions": rust_core, + }, + "wasm_node": { + "export_count": len(wasm_exports), + "exports": wasm_exports, + }, + }, + "parity_summary": { + "python_indicator_count": len(python_indicator_names_lc), + "wasm_export_count": len(wasm_set), + "common_python_wasm_count": len(common_with_wasm), + "common_python_wasm": common_with_wasm, + "python_only_vs_wasm": sorted(python_indicator_names_lc - wasm_set_lc), + "wasm_only_vs_python": sorted(wasm_set_lc - python_indicator_names_lc), + }, + } + + if include_runtime_metadata: + manifest["generated_at_utc"] = _dt.datetime.now(tz=_dt.UTC).isoformat() + manifest["git_head"] = _safe_git_head(root) + + return manifest + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build cross-surface API manifest") + parser.add_argument( + "--output", + type=Path, + default=Path("docs/api_manifest.json"), + help="Output JSON path relative to repo root (default: docs/api_manifest.json)", + ) + parser.add_argument( + "--include-runtime-metadata", + action="store_true", + help=( + "Include non-deterministic metadata fields (timestamp, git head). " + "Disabled by default to keep manifest reproducible for CI checks." + ), + ) + args = parser.parse_args() + + root = _repo_root() + output_path = (root / args.output).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + manifest = build_manifest( + root, include_runtime_metadata=args.include_runtime_metadata + ) + output_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print(f"Wrote API manifest to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/ferro-ta-main/scripts/bump_version.py b/ferro-ta-main/scripts/bump_version.py new file mode 100644 index 0000000..5505c23 --- /dev/null +++ b/ferro-ta-main/scripts/bump_version.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Update or verify ferro-ta version strings across release files. + +Usage +----- +python3 scripts/bump_version.py 1.0.3 +python3 scripts/bump_version.py --check +python3 scripts/bump_version.py --show +""" + +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +@dataclass(frozen=True) +class VersionCarrier: + label: str + path: Path + pattern: str + replacement: str + + def read(self) -> str: + text = self.path.read_text(encoding="utf-8") + match = re.search(self.pattern, text, flags=re.MULTILINE) + if not match: + raise ValueError(f"Could not find version for {self.label} in {self.path}") + return match.group(2) + + def write(self, version: str) -> bool: + text = self.path.read_text(encoding="utf-8") + updated, count = re.subn( + self.pattern, + rf"\g<1>{version}\g<3>", + text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise ValueError(f"Could not update {self.label} in {self.path}") + changed = updated != text + if changed: + self.path.write_text(updated, encoding="utf-8") + return changed + + +CARRIERS = [ + VersionCarrier( + "cargo_root", + ROOT / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_dep", + ROOT / "Cargo.toml", + r'(ferro_ta_core = \{ path = "crates/ferro_ta_core", version = ")([^"]+)("[^}]*\})', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_crate", + ROOT / "crates" / "ferro_ta_core" / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_readme", + ROOT / "crates" / "ferro_ta_core" / "README.md", + r'(ferro_ta_core = ")([^"]+)(")', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "pyproject", + ROOT / "pyproject.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "wasm_cargo", + ROOT / "wasm" / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "wasm_package", + ROOT / "wasm" / "package.json", + r'("version": ")([^"]+)(")', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "conda", + ROOT / "conda" / "meta.yaml", + r'({% set version = ")([^"]+)(" %})', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "docs_changelog", + ROOT / "docs" / "changelog.rst", + r"(These docs track package version ``)([^`]+)(``\.)", + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "docs_support_matrix", + ROOT / "docs" / "support_matrix.rst", + r"(These docs track package version ``)([^`]+)(``\.)", + r"\g<1>{version}\g<3>", + ), +] + + +def _read_versions() -> dict[str, str]: + return {carrier.label: carrier.read() for carrier in CARRIERS} + + +def _print_versions(versions: dict[str, str]) -> None: + for label, version in versions.items(): + print(f"{label:20} {version}") + + +def _check_versions() -> int: + versions = _read_versions() + unique = sorted(set(versions.values())) + _print_versions(versions) + if len(unique) != 1: + print() + print(f"ERROR: version mismatch detected: {', '.join(unique)}") + return 1 + print() + print(f"OK: all tracked versions match {unique[0]}") + return 0 + + +def _set_version(version: str) -> int: + if not SEMVER_RE.match(version): + print(f"ERROR: expected MAJOR.MINOR.PATCH, got {version!r}") + return 1 + + changed_paths: list[Path] = [] + for carrier in CARRIERS: + if carrier.write(version): + changed_paths.append(carrier.path) + + if changed_paths: + print(f"Updated version to {version}:") + for path in sorted(set(changed_paths)): + print(f" - {path.relative_to(ROOT)}") + else: + print(f"No changes needed. All tracked files already use {version}.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", nargs="?", help="New version to write") + parser.add_argument( + "--check", + action="store_true", + help="Fail if tracked version strings do not match", + ) + parser.add_argument( + "--show", + action="store_true", + help="Print tracked version strings without modifying files", + ) + args = parser.parse_args() + + if args.check: + return _check_versions() + if args.show: + _print_versions(_read_versions()) + return 0 + if args.version: + return _set_version(args.version) + + parser.print_help() + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/scripts/check_api_manifest.py b/ferro-ta-main/scripts/check_api_manifest.py new file mode 100644 index 0000000..dd162be --- /dev/null +++ b/ferro-ta-main/scripts/check_api_manifest.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Check that docs/api_manifest.json is up-to-date. + +This script regenerates the deterministic manifest in-memory and compares it to +the committed file. It exits non-zero if drift is detected. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + python_root = str(root / "python") + if python_root not in sys.path: + sys.path.insert(0, python_root) + scripts_root = str(root / "scripts") + if scripts_root not in sys.path: + sys.path.insert(0, scripts_root) + + from build_api_manifest import build_manifest + + manifest_path = root / "docs" / "api_manifest.json" + + if not manifest_path.exists(): + print( + "docs/api_manifest.json is missing. Run:\n" + " python scripts/build_api_manifest.py --output docs/api_manifest.json" + ) + return 1 + + expected = build_manifest(root, include_runtime_metadata=False) + actual = json.loads(manifest_path.read_text(encoding="utf-8")) + + if actual != expected: + print( + "docs/api_manifest.json is out of date.\n" + "Run:\n" + " python scripts/build_api_manifest.py --output docs/api_manifest.json\n" + "and commit the updated file." + ) + return 1 + + print("docs/api_manifest.json is up to date.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/scripts/check_changelog.py b/ferro-ta-main/scripts/check_changelog.py new file mode 100644 index 0000000..e879198 --- /dev/null +++ b/ferro-ta-main/scripts/check_changelog.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Validate that CHANGELOG.md keeps a single top-level [Unreleased] section.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +def main() -> int: + changelog = Path("CHANGELOG.md") + if not changelog.exists(): + print("ERROR: CHANGELOG.md not found.") + return 1 + + text = changelog.read_text(encoding="utf-8") + headings = list(re.finditer(r"^## \[(.+?)\]\s*$", text, flags=re.MULTILINE)) + unreleased = [m for m in headings if m.group(1) == "Unreleased"] + + if not unreleased: + print("ERROR: CHANGELOG.md is missing a '## [Unreleased]' heading.") + return 1 + if len(unreleased) > 1: + print("ERROR: CHANGELOG.md contains multiple '## [Unreleased]' headings.") + return 1 + + if headings and headings[0].group(1) != "Unreleased": + print("ERROR: '## [Unreleased]' must be the first top-level changelog section.") + return 1 + + print("OK: CHANGELOG.md contains a single top-level [Unreleased] section.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ferro-ta-main/scripts/pre_push_checks.sh b/ferro-ta-main/scripts/pre_push_checks.sh new file mode 100644 index 0000000..0417d4c --- /dev/null +++ b/ferro-ta-main/scripts/pre_push_checks.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# Pre-push CI gate — runs checks in parallel to minimise wall-clock time. +# +# Usage: +# scripts/pre_push_checks.sh # all checks +# scripts/pre_push_checks.sh rust_clippy wasm # selected checks +# scripts/pre_push_checks.sh --list +# FERRO_FAST=1 scripts/pre_push_checks.sh # skip docs + wasm bench +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +AVAILABLE_CHECKS=( + version changelog manifest + rust_fmt rust_clippy rust_core rust_bench + python_lint python_typecheck python_test + docs wasm +) +DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}") + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2; exit 1 + fi +} + +run_cmd() { + printf ' +' + printf ' %q' "$@" + printf '\n' + "$@" +} + +usage() { + cat <<'EOF' +Usage: + scripts/pre_push_checks.sh + scripts/pre_push_checks.sh [ ...] + scripts/pre_push_checks.sh --list + +Environment: + FERRO_FAST=1 Skip docs and wasm (fastest local feedback loop) +EOF +} + +# --------------------------------------------------------------------------- +# Individual check functions +# --------------------------------------------------------------------------- + +run_version() { need_cmd python3; run_cmd python3 scripts/bump_version.py --check; } +run_changelog() { need_cmd python3; run_cmd python3 scripts/check_changelog.py; } +run_manifest() { need_cmd python3; run_cmd python3 scripts/check_api_manifest.py; } +run_rust_fmt() { need_cmd cargo; run_cmd cargo fmt --all -- --check; } +run_python_lint() { + need_cmd uv + run_cmd uv run --with ruff ruff check python/ tests/ + run_cmd uv run --with ruff ruff format --check python/ tests/ +} + +run_rust_clippy() { need_cmd cargo; run_cmd cargo clippy --release -- -D warnings; } +run_rust_core() { need_cmd cargo; run_cmd cargo build -p ferro_ta_core && run_cmd cargo test -p ferro_ta_core; } +run_rust_bench() { need_cmd cargo; run_cmd cargo bench -p ferro_ta_core --no-run; } + +run_python_typecheck() { + need_cmd uv + run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta \ + --ignore-missing-imports --no-error-summary + run_cmd uv run --with pyright python -m pyright python/ferro_ta +} + +# python_test and docs both need a compiled extension. +# Use a flag file so only the first concurrent caller runs maturin develop; +# subsequent callers (in parallel background jobs) wait and reuse it. +_MATURIN_LOCK="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.lock" +_MATURIN_FLAG="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.done" + +ensure_python_env() { + [[ -f "$_MATURIN_FLAG" ]] && return + ( + flock 9 + if [[ ! -f "$_MATURIN_FLAG" ]]; then + need_cmd uv + run_cmd uv sync --extra dev --extra docs --extra mcp + run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release + touch "$_MATURIN_FLAG" + fi + ) 9>"$_MATURIN_LOCK" +} + +run_python_test() { + ensure_python_env + run_cmd uv run --extra dev --extra mcp --with pytest-cov \ + pytest tests/unit/ tests/integration/ \ + -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65 +} + +run_docs() { + ensure_python_env + run_cmd uv run --extra docs python -m sphinx -b html docs docs/_build -W --keep-going +} + +run_wasm() { + need_cmd node; need_cmd wasm-pack + ( + cd wasm + run_cmd wasm-pack test --node + run_cmd npm run build + if [[ "${FERRO_FAST:-0}" != "1" ]]; then + local bj="../.wasm_benchmark.prepush.json" + run_cmd node bench.js --json "$bj" + rm -f "$bj" + fi + ) +} + +run_check() { + case "$1" in + version) run_version ;; + changelog) run_changelog ;; + manifest) run_manifest ;; + rust_fmt) run_rust_fmt ;; + rust_clippy) run_rust_clippy ;; + rust_core) run_rust_core ;; + rust_bench) run_rust_bench ;; + python_lint) run_python_lint ;; + python_typecheck) run_python_typecheck ;; + python_test) run_python_test ;; + docs) run_docs ;; + wasm) run_wasm ;; + *) echo "Unknown check: $1 — use --list" >&2; exit 1 ;; + esac +} + +# --------------------------------------------------------------------------- +# Parallel runner — starts all checks concurrently, collects results +# --------------------------------------------------------------------------- + +run_parallel() { + local -a checks=("$@") + [[ "${#checks[@]}" -eq 0 ]] && return 0 + + local -a pids logs names + local start + start=$(date +%s) + + printf '\nStarting %d checks in parallel: %s\n' "${#checks[@]}" "${checks[*]}" + + for check in "${checks[@]}"; do + local log + log=$(mktemp /tmp/ferro_prepush_XXXXXX) + logs+=("$log") + names+=("$check") + run_check "$check" >"$log" 2>&1 & + pids+=($!) + done + + local failed=0 + local -a failed_names + printf '\n' + for i in "${!pids[@]}"; do + if wait "${pids[$i]}" 2>/dev/null; then + printf ' ✓ %s\n' "${names[$i]}" + else + printf ' ✗ %s\n' "${names[$i]}" + failed_names+=("${names[$i]}") + failed=1 + fi + done + + # Print logs for failed checks only + if [[ "$failed" -eq 1 ]]; then + for i in "${!names[@]}"; do + local name="${names[$i]}" + if [[ " ${failed_names[*]:-} " == *" $name "* ]]; then + printf '\n'; printf '━%.0s' {1..60}; printf '\nFAILED: %s\n' "$name"; printf '━%.0s' {1..60}; printf '\n' + cat "${logs[$i]}" + fi + done + fi + + for log in "${logs[@]}"; do rm -f "$log"; done + rm -f "$_MATURIN_LOCK" "$_MATURIN_FLAG" + + printf '\nElapsed: %ds\n' "$(( $(date +%s) - start ))" + return "$failed" +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; } +[[ "${1:-}" == "--list" ]] && { printf '%s\n' "${AVAILABLE_CHECKS[@]}"; exit 0; } + +selected_checks=() +if [[ "$#" -gt 0 ]]; then + selected_checks=("$@") +else + selected_checks=("${DEFAULT_CHECKS[@]}") + if [[ "${FERRO_FAST:-0}" == "1" ]]; then + selected_checks=() + for c in "${DEFAULT_CHECKS[@]}"; do + [[ "$c" == "docs" || "$c" == "wasm" ]] && continue + selected_checks+=("$c") + done + printf 'FERRO_FAST=1: skipping docs + wasm\n' + fi +fi + +# --------------------------------------------------------------------------- +# Execution strategy: +# Phase 1 — instant gate (sequential, fail-fast): +# version, changelog, manifest, python_lint, rust_fmt +# These are trivial to run and catch the most common mistakes early. +# If any fail here we abort immediately without waiting for slow checks. +# +# Phase 2 — everything else in parallel: +# rust_clippy, rust_core, rust_bench, python_typecheck, +# python_test, docs, wasm +# --------------------------------------------------------------------------- + +FAST_CHECKS=(version changelog manifest python_lint rust_fmt) + +phase1=() +phase2=() +for c in "${selected_checks[@]}"; do + is_fast=0 + for f in "${FAST_CHECKS[@]}"; do [[ "$c" == "$f" ]] && is_fast=1 && break; done + if [[ "$is_fast" -eq 1 ]]; then phase1+=("$c"); else phase2+=("$c"); fi +done + +# Phase 1: fast gate +if [[ "${#phase1[@]}" -gt 0 ]]; then + printf 'Phase 1 — fast gate (%d checks)\n' "${#phase1[@]}" + start1=$(date +%s) + for c in "${phase1[@]}"; do + printf ' [%s] ... ' "$c" + log=$(mktemp /tmp/ferro_prepush_XXXXXX) + if run_check "$c" >"$log" 2>&1; then + printf 'ok\n' + else + printf 'FAILED\n' + cat "$log" + rm -f "$log" + echo "" >&2 + echo "Fast gate failed on '$c' — aborting before slow checks." >&2 + exit 1 + fi + rm -f "$log" + done + printf 'Phase 1 passed (%ds)\n' "$(( $(date +%s) - start1 ))" +fi + +# Phase 2: parallel slow checks +if [[ "${#phase2[@]}" -gt 0 ]]; then + printf '\nPhase 2 — parallel slow checks\n' + run_parallel "${phase2[@]}" || exit 1 +fi + +printf '\nAll pre-push checks passed.\n' diff --git a/ferro-ta-main/src/aggregation/mod.rs b/ferro-ta-main/src/aggregation/mod.rs new file mode 100644 index 0000000..f1e4c58 --- /dev/null +++ b/ferro-ta-main/src/aggregation/mod.rs @@ -0,0 +1,119 @@ +//! Tick/trade aggregation (thin PyO3 wrapper over ferro_ta_core::aggregation). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +type Ohlcv5<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +type Ohlcv5AndLabels<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +/// Aggregate tick/trade data into tick bars (every N ticks become one bar). +#[pyfunction] +#[pyo3(signature = (price, size, ticks_per_bar))] +pub fn aggregate_tick_bars<'py>( + py: Python<'py>, + price: PyReadonlyArray1<'py, f64>, + size: PyReadonlyArray1<'py, f64>, + ticks_per_bar: usize, +) -> PyResult> { + if ticks_per_bar == 0 { + return Err(PyValueError::new_err("ticks_per_bar must be >= 1")); + } + let p = price.as_slice()?; + let s = size.as_slice()?; + let n = p.len(); + if n == 0 || s.len() != n { + return Err(PyValueError::new_err( + "price and size must be non-empty and equal length", + )); + } + let (ro, rh, rl, rc, rv) = ferro_ta_core::aggregation::aggregate_tick_bars(p, s, ticks_per_bar); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + )) +} + +/// Aggregate tick data into volume bars (fixed volume threshold). +#[pyfunction] +#[pyo3(signature = (price, size, volume_threshold))] +pub fn aggregate_volume_bars_ticks<'py>( + py: Python<'py>, + price: PyReadonlyArray1<'py, f64>, + size: PyReadonlyArray1<'py, f64>, + volume_threshold: f64, +) -> PyResult> { + if volume_threshold <= 0.0 { + return Err(PyValueError::new_err("volume_threshold must be > 0")); + } + let p = price.as_slice()?; + let s = size.as_slice()?; + let n = p.len(); + if n == 0 || s.len() != n { + return Err(PyValueError::new_err( + "price and size must be non-empty and equal length", + )); + } + let (ro, rh, rl, rc, rv) = + ferro_ta_core::aggregation::aggregate_volume_bars_ticks(p, s, volume_threshold); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + )) +} + +/// Aggregate tick data into time bars using pre-computed integer bucket labels. +#[pyfunction] +#[pyo3(signature = (price, size, labels))] +pub fn aggregate_time_bars<'py>( + py: Python<'py>, + price: PyReadonlyArray1<'py, f64>, + size: PyReadonlyArray1<'py, f64>, + labels: PyReadonlyArray1<'py, i64>, +) -> PyResult> { + let p = price.as_slice()?; + let s = size.as_slice()?; + let lbl = labels.as_slice()?; + let n = p.len(); + if n == 0 || s.len() != n || lbl.len() != n { + return Err(PyValueError::new_err( + "price, size, and labels must be non-empty and equal length", + )); + } + let (ro, rh, rl, rc, rv, rlbl) = ferro_ta_core::aggregation::aggregate_time_bars(p, s, lbl); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + rlbl.into_pyarray(py), + )) +} + +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)?)?; + m.add_function(wrap_pyfunction!(aggregate_time_bars, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/alerts/mod.rs b/ferro-ta-main/src/alerts/mod.rs new file mode 100644 index 0000000..cd40b52 --- /dev/null +++ b/ferro-ta-main/src/alerts/mod.rs @@ -0,0 +1,73 @@ +//! Alerts — condition evaluation helpers (thin PyO3 wrapper over ferro_ta_core::alerts). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Fire an alert when *series* crosses a threshold level. +/// +/// Parameters +/// ---------- +/// series : 1-D float64 array +/// level : float — threshold value +/// direction : int — ``1`` (cross above) or ``-1`` (cross below) +/// +/// Returns +/// ------- +/// 1-D int8 array — 1 at crossing bars, 0 elsewhere. +#[pyfunction] +pub fn check_threshold<'py>( + py: Python<'py>, + series: PyReadonlyArray1<'py, f64>, + level: f64, + direction: i32, +) -> PyResult>> { + if direction != 1 && direction != -1 { + return Err(PyValueError::new_err( + "direction must be 1 (cross above) or -1 (cross below)", + )); + } + let s = series.as_slice()?; + let result = ferro_ta_core::alerts::check_threshold(s, level, direction); + Ok(result.into_pyarray(py)) +} + +/// Detect cross-over / cross-under events between two series. +/// +/// Returns +/// ------- +/// 1-D int8 array: ``1`` = bullish, ``-1`` = bearish, ``0`` = none. +#[pyfunction] +pub fn check_cross<'py>( + py: Python<'py>, + fast: PyReadonlyArray1<'py, f64>, + slow: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let f = fast.as_slice()?; + let s = slow.as_slice()?; + if f.len() != s.len() { + return Err(PyValueError::new_err( + "fast and slow must have the same length", + )); + } + let result = ferro_ta_core::alerts::check_cross(f, s); + Ok(result.into_pyarray(py)) +} + +/// 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 result = ferro_ta_core::alerts::collect_alert_bars(m); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(check_threshold, m)?)?; + m.add_function(wrap_pyfunction!(check_cross, m)?)?; + m.add_function(wrap_pyfunction!(collect_alert_bars, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/attribution/mod.rs b/ferro-ta-main/src/attribution/mod.rs new file mode 100644 index 0000000..c38167b --- /dev/null +++ b/ferro-ta-main/src/attribution/mod.rs @@ -0,0 +1,79 @@ +//! Performance attribution (thin PyO3 wrapper over ferro_ta_core::attribution). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::validation; + +/// Compute trade-level statistics from trade PnL and hold durations. +#[pyfunction] +pub fn trade_stats( + pnl: PyReadonlyArray1<'_, f64>, + hold_bars: PyReadonlyArray1<'_, f64>, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let p = pnl.as_slice()?; + let h = hold_bars.as_slice()?; + let n = p.len(); + if n == 0 { + return Err(PyValueError::new_err("pnl must be non-empty")); + } + validation::validate_equal_length(&[(n, "pnl"), (h.len(), "hold_bars")])?; + Ok(ferro_ta_core::attribution::trade_stats(p, h)) +} + +/// Group per-bar returns by month index and sum each month's contribution. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn monthly_contribution<'py>( + py: Python<'py>, + bar_returns: PyReadonlyArray1<'py, f64>, + month_index: PyReadonlyArray1<'py, i64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let ret = bar_returns.as_slice()?; + let mi = month_index.as_slice()?; + let n = ret.len(); + validation::validate_equal_length(&[(n, "bar_returns"), (mi.len(), "month_index")])?; + let (months, contributions) = ferro_ta_core::attribution::monthly_contribution(ret, mi); + Ok((months.into_pyarray(py), contributions.into_pyarray(py))) +} + +/// Attribute per-bar returns to each signal label. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn signal_attribution<'py>( + py: Python<'py>, + bar_returns: PyReadonlyArray1<'py, f64>, + signal_labels: PyReadonlyArray1<'py, i64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let ret = bar_returns.as_slice()?; + let lbl = signal_labels.as_slice()?; + let n = ret.len(); + validation::validate_equal_length(&[(n, "bar_returns"), (lbl.len(), "signal_labels")])?; + let (labels, contributions) = ferro_ta_core::attribution::signal_attribution(ret, lbl); + Ok((labels.into_pyarray(py), contributions.into_pyarray(py))) +} + +/// Extract trade-level pnl and hold durations from positions and strategy returns. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn extract_trades<'py>( + py: Python<'py>, + positions: PyReadonlyArray1<'py, f64>, + strategy_returns: PyReadonlyArray1<'py, f64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let pos = positions.as_slice()?; + let ret = strategy_returns.as_slice()?; + let n = pos.len(); + validation::validate_equal_length(&[(n, "positions"), (ret.len(), "strategy_returns")])?; + let (pnl, hold) = ferro_ta_core::attribution::extract_trades(pos, ret); + Ok((pnl.into_pyarray(py), hold.into_pyarray(py))) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(trade_stats, m)?)?; + m.add_function(wrap_pyfunction!(monthly_contribution, m)?)?; + m.add_function(wrap_pyfunction!(signal_attribution, m)?)?; + m.add_function(wrap_pyfunction!(extract_trades, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/backtest/commission.rs b/ferro-ta-main/src/backtest/commission.rs new file mode 100644 index 0000000..0338a85 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/src/backtest/currency.rs b/ferro-ta-main/src/backtest/currency.rs new file mode 100644 index 0000000..f0a6b39 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/src/backtest/mod.rs b/ferro-ta-main/src/backtest/mod.rs new file mode 100644 index 0000000..5d4997a --- /dev/null +++ b/ferro-ta-main/src/backtest/mod.rs @@ -0,0 +1,821 @@ +//! Thin PyO3 wrappers delegating to `ferro_ta_core::backtest`. + +pub mod commission; +pub mod currency; + +use commission::PyCommissionModel; +use currency::PyCurrency; +use ferro_ta_core::backtest as core_bt; +use ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use rayon::prelude::*; + +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, + } + } +} + +// --------------------------------------------------------------------------- +// Signal generators +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14, oversold = 30.0, overbought = 70.0))] +pub fn rsi_threshold_signals<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + oversold: f64, + overbought: f64, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let out = core_bt::rsi_threshold_signals(prices, timeperiod, oversold, overbought); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (close, fast = 10, slow = 30))] +pub fn sma_crossover_signals<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fast: usize, + slow: usize, +) -> PyResult>> { + validation::validate_timeperiod(fast, "fast", 1)?; + validation::validate_timeperiod(slow, "slow", 1)?; + let prices = close.as_slice()?; + let out = core_bt::sma_crossover_signals(prices, fast, slow).map_err(PyValueError::new_err)?; + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))] +pub fn macd_crossover_signals<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + validation::validate_timeperiod(signalperiod, "signalperiod", 1)?; + let prices = close.as_slice()?; + let out = core_bt::macd_crossover_signals(prices, fastperiod, slowperiod, signalperiod) + .map_err(PyValueError::new_err)?; + Ok(out.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// Backtest core (close-only) +// --------------------------------------------------------------------------- + +#[pyfunction] +#[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: 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(PyValueError::new_err)?; + + Ok(( + result.positions.into_pyarray(py), + result.bar_returns.into_pyarray(py), + result.strategy_returns.into_pyarray(py), + result.equity.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// OHLCV backtest +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = ( + open, high, low, close, signals, + fill_mode = "market_open", + stop_loss_pct = 0.0, + take_profit_pct = 0.0, + trailing_stop_pct = 0.0, + commission = None, + slippage_bps = 0.0, + initial_capital = 100_000.0, + commission_per_trade = 0.0, + limit_prices = None, + max_hold_bars = 0, + slippage_pct_range = 0.0, + breakeven_pct = 0.0, + periods_per_year = 252.0, + margin_ratio = 0.0, + margin_call_pct = 0.5, + daily_loss_limit = 0.0, + total_loss_limit = 0.0, +))] +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +pub fn backtest_ohlcv_core<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + signals: PyReadonlyArray1<'py, f64>, + fill_mode: &str, + stop_loss_pct: f64, + take_profit_pct: f64, + trailing_stop_pct: f64, + commission: Option>, + 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"), + (o.len(), "open"), + (h.len(), "high"), + (l.len(), "low"), + (s.len(), "signals"), + ])?; + + let config = core_bt::BacktestConfig { + fill_mode: fill_mode.to_string(), + stop_loss_pct, + take_profit_pct, + trailing_stop_pct, + slippage_bps, + initial_capital, + commission_per_trade, + max_hold_bars, + slippage_pct_range, + breakeven_pct, + periods_per_year, + margin_ratio, + margin_call_pct, + daily_loss_limit, + total_loss_limit, + commission: commission.as_ref().map(|c| c.inner.clone()), + }; + + let lp_opt: Option<&[f64]> = limit_prices.as_ref().and_then(|lp| lp.as_slice().ok()); + + let result = core_bt::backtest_ohlcv_core(o, h, l, c, s, &config, lp_opt) + .map_err(PyValueError::new_err)?; + + Ok(( + result.positions.into_pyarray(py), + result.fill_prices.into_pyarray(py), + result.bar_returns.into_pyarray(py), + result.strategy_returns.into_pyarray(py), + result.equity.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// Performance metrics +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (strategy_returns, equity, periods_per_year = 252.0, risk_free_rate = 0.0, benchmark_returns = None))] +pub fn compute_performance_metrics<'py>( + py: Python<'py>, + strategy_returns: PyReadonlyArray1<'py, f64>, + equity: PyReadonlyArray1<'py, f64>, + periods_per_year: f64, + risk_free_rate: f64, + benchmark_returns: Option>, +) -> 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(PyValueError::new_err)?; + + let dict = PyDict::new(py); + dict.set_item("total_return", metrics.total_return)?; + dict.set_item("cagr", metrics.cagr)?; + dict.set_item("annualized_vol", metrics.annualized_vol)?; + dict.set_item("sharpe", metrics.sharpe)?; + dict.set_item("sortino", metrics.sortino)?; + dict.set_item("calmar", metrics.calmar)?; + dict.set_item("max_drawdown", metrics.max_drawdown)?; + dict.set_item("avg_drawdown", metrics.avg_drawdown)?; + dict.set_item( + "max_drawdown_duration_bars", + metrics.max_drawdown_duration_bars as i64, + )?; + dict.set_item( + "avg_drawdown_duration_bars", + metrics.avg_drawdown_duration_bars, + )?; + dict.set_item("ulcer_index", metrics.ulcer_index)?; + dict.set_item("omega_ratio", metrics.omega_ratio)?; + dict.set_item("win_rate", metrics.win_rate)?; + dict.set_item("profit_factor", metrics.profit_factor)?; + dict.set_item("r_expectancy", metrics.r_expectancy)?; + dict.set_item("avg_win", metrics.avg_win)?; + dict.set_item("avg_loss", metrics.avg_loss)?; + dict.set_item("tail_ratio", metrics.tail_ratio)?; + dict.set_item("skewness", metrics.skewness)?; + dict.set_item("kurtosis", metrics.kurtosis)?; + dict.set_item("best_bar", metrics.best_bar)?; + dict.set_item("worst_bar", metrics.worst_bar)?; + dict.set_item("n_trades", metrics.n_trades as i64)?; + dict.set_item("n_position_changes", metrics.n_position_changes as i64)?; + + if let Some(v) = metrics.benchmark_total_return { + dict.set_item("benchmark_total_return", v)?; + } + if let Some(v) = metrics.benchmark_cagr { + dict.set_item("benchmark_cagr", v)?; + } + if let Some(v) = metrics.benchmark_annualized_vol { + dict.set_item("benchmark_annualized_vol", v)?; + } + if let Some(v) = metrics.benchmark_sharpe { + dict.set_item("benchmark_sharpe", v)?; + } + if let Some(v) = metrics.alpha { + dict.set_item("alpha", v)?; + } + if let Some(v) = metrics.beta { + dict.set_item("beta", v)?; + } + if let Some(v) = metrics.tracking_error { + dict.set_item("tracking_error", v)?; + } + if let Some(v) = metrics.information_ratio { + dict.set_item("information_ratio", v)?; + } + + Ok(dict) +} + +// --------------------------------------------------------------------------- +// Trade extraction +// --------------------------------------------------------------------------- + +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn extract_trades_ohlcv<'py>( + py: Python<'py>, + positions: PyReadonlyArray1<'py, f64>, + fill_prices: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, +) -> PyResult<( + Bound<'py, PyArray1>, + 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()?; + + validation::validate_equal_length(&[ + (pos.len(), "positions"), + (fp.len(), "fill_prices"), + (h.len(), "high"), + (l.len(), "low"), + ])?; + + let trades = core_bt::extract_trades_ohlcv(pos, fp, h, l).map_err(PyValueError::new_err)?; + + let mut entry_bars: Vec = 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(( + 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 + #[allow(clippy::needless_range_loop)] + if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 { + for i in 0..n_bars { + if max_asset_weight < f64::INFINITY && max_asset_weight > 0.0 { + for j in 0..n_assets { + let w = weights_cm[j][i]; + if w.abs() > max_asset_weight { + weights_cm[j][i] = w.signum() * max_asset_weight; + } + } + } + if max_gross_exposure > 0.0 { + let gross: f64 = (0..n_assets).map(|j| weights_cm[j][i].abs()).sum(); + if gross > max_gross_exposure { + let scale = max_gross_exposure / gross; + for j in 0..n_assets { + weights_cm[j][i] *= scale; + } + } + } + if max_net_exposure > 0.0 { + let net: f64 = (0..n_assets).map(|j| weights_cm[j][i]).sum(); + if net.abs() > max_net_exposure { + let excess = net - net.signum() * max_net_exposure; + let adj_per_asset = excess / n_assets as f64; + for j in 0..n_assets { + weights_cm[j][i] -= adj_per_asset; + } + } + } + } + } + + // 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(PyValueError::new_err)?; + + 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(PyValueError::new_err) +} + +#[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(PyValueError::new_err) +} + +// --------------------------------------------------------------------------- +// StreamingBacktest +// --------------------------------------------------------------------------- + +#[pyclass(name = "StreamingBacktest")] +pub struct StreamingBacktest { + inner: core_bt::StreamingBacktest, +} + +#[pymethods] +impl StreamingBacktest { + #[new] + #[pyo3(signature = (commission_per_trade=0.0, slippage_bps=0.0))] + pub fn new(commission_per_trade: f64, slippage_bps: f64) -> Self { + StreamingBacktest { + inner: core_bt::StreamingBacktest::new(commission_per_trade, slippage_bps), + } + } + + pub fn on_bar<'py>( + &mut self, + py: Python<'py>, + close: f64, + signal: f64, + ) -> PyResult> { + 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/ferro-ta-main/src/batch/mod.rs b/ferro-ta-main/src/batch/mod.rs new file mode 100644 index 0000000..0a6b2bf --- /dev/null +++ b/ferro-ta-main/src/batch/mod.rs @@ -0,0 +1,450 @@ +//! Rust-side batch execution — run SMA/EMA/RSI over all columns of a 2-D +//! array in a **single GIL release**, avoiding per-column Python round-trips. +//! +//! Python shapes: `(n_samples, n_series)` — C-contiguous row-major. +//! Rust iterates over columns (series) and rows (time) inside native code. +//! +//! When `parallel = true` (default), columns are processed in parallel via +//! [Rayon](https://docs.rs/rayon) after releasing the GIL. For small inputs +//! the sequential path (`parallel = false`) may be faster due to thread-pool +//! overhead. +//! +//! All indicator logic lives in `ferro_ta_core::batch`. This module is a thin +//! PyO3 wrapper that converts numpy ↔ Rust types and optionally adds Rayon +//! parallelism. + +use ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use rayon::prelude::*; + +// --------------------------------------------------------------------------- +// 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( + expected: (usize, usize), + actual: (usize, usize), + name: &str, +) -> PyResult<()> { + if actual == expected { + Ok(()) + } else { + Err(PyValueError::new_err(format!( + "{name} must have shape {:?}, got {:?}", + expected, actual + ))) + } +} + +fn map_core_err(err: String) -> PyErr { + PyValueError::new_err(err) +} + +// --------------------------------------------------------------------------- +// Parallel-aware unary batch helper +// --------------------------------------------------------------------------- + +/// 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, + per_col: F, +) -> PyResult>> +where + F: Fn(&[f64]) -> Vec + Sync, +{ + let arr = data.as_array(); + let (n_samples, _n_series) = arr.dim(); + let columns = numpy2d_to_columns(&arr); + + let col_results: Vec> = py.allow_threads(|| { + if parallel { + columns.par_iter().map(|col| per_col(col)).collect() + } else { + columns.iter().map(|col| per_col(col)).collect() + } + }); + + Ok(columns_to_numpy2d(py, n_samples, col_results)) +} + +// --------------------------------------------------------------------------- +// batch_sma +// --------------------------------------------------------------------------- + +/// Batch Simple Moving Average — applies SMA to every column of a 2-D array. +/// +/// 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 +/// Same shape as input; first ``timeperiod-1`` rows are NaN. +#[pyfunction] +#[pyo3(signature = (data, timeperiod = 30, parallel = true))] +pub fn batch_sma<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let (n_samples, n_series) = data.as_array().dim(); + log::debug!( + "batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" + ); + run_unary_batch_par(py, data, parallel, |col| { + ferro_ta_core::overlap::sma(col, timeperiod) + }) +} + +// --------------------------------------------------------------------------- +// batch_ema +// --------------------------------------------------------------------------- + +/// Batch Exponential Moving Average — applies EMA to every column. +#[pyfunction] +#[pyo3(signature = (data, timeperiod = 30, parallel = true))] +pub fn batch_ema<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let (n_samples, n_series) = data.as_array().dim(); + log::debug!( + "batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" + ); + run_unary_batch_par(py, data, parallel, |col| { + ferro_ta_core::overlap::ema(col, timeperiod) + }) +} + +// --------------------------------------------------------------------------- +// batch_rsi +// --------------------------------------------------------------------------- + +/// Batch RSI — applies RSI (Wilder seeding) to every column. +#[pyfunction] +#[pyo3(signature = (data, timeperiod = 14, parallel = true))] +pub fn batch_rsi<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let (n_samples, n_series) = data.as_array().dim(); + log::debug!( + "batch_rsi: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" + ); + run_unary_batch_par(py, data, parallel, |col| { + ferro_ta_core::momentum::rsi(col, timeperiod) + }) +} + +// --------------------------------------------------------------------------- +// batch_atr +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14, parallel = true))] +pub fn batch_atr<'py>( + py: Python<'py>, + high: PyReadonlyArray2<'py, f64>, + low: PyReadonlyArray2<'py, f64>, + close: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let arr_h = high.as_array(); + let arr_l = low.as_array(); + let arr_c = close.as_array(); + let (n_samples, n_series) = arr_h.dim(); + validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?; + validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?; + + let h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); + + let col_results: Vec> = py.allow_threads(|| { + 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).collect() + } else { + (0..n_series).map(process).collect() + } + }); + Ok(columns_to_numpy2d(py, n_samples, col_results)) +} + +// --------------------------------------------------------------------------- +// batch_stoch +// --------------------------------------------------------------------------- + +type StochBatchResult<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray2>); + +#[pyfunction] +#[pyo3(signature = (high, low, close, fastk_period = 5, slowk_period = 3, slowd_period = 3, parallel = true))] +#[allow(clippy::too_many_arguments)] +pub fn batch_stoch<'py>( + py: Python<'py>, + high: PyReadonlyArray2<'py, f64>, + low: PyReadonlyArray2<'py, f64>, + close: PyReadonlyArray2<'py, f64>, + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, + parallel: bool, +) -> PyResult> { + let arr_h = high.as_array(); + let arr_l = low.as_array(); + let arr_c = close.as_array(); + let (n_samples, n_series) = arr_h.dim(); + validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?; + validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?; + + let h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); + + let col_results: Vec<(Vec, Vec)> = py.allow_threads(|| { + let process = |i: usize| { + ferro_ta_core::momentum::stoch( + &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).collect() + } else { + (0..n_series).map(process).collect() + } + }); + + let (all_k, all_d): (Vec>, Vec>) = col_results.into_iter().unzip(); + Ok(column_pair_to_numpy2d(py, n_samples, all_k, all_d)) +} + +// --------------------------------------------------------------------------- +// batch_adx +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14, parallel = true))] +pub fn batch_adx<'py>( + py: Python<'py>, + high: PyReadonlyArray2<'py, f64>, + low: PyReadonlyArray2<'py, f64>, + close: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let arr_h = high.as_array(); + let arr_l = low.as_array(); + let arr_c = close.as_array(); + let (n_samples, n_series) = arr_h.dim(); + validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?; + validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?; + + let h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); + + let col_results: Vec> = py.allow_threads(|| { + 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).collect() + } else { + (0..n_series).map(process).collect() + } + }); + 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>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + names: Vec, + timeperiods: Vec, + parallel: bool, +) -> PyResult { + let close_values = close.as_slice()?; + + 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] +#[pyo3(signature = (high, low, close, names, timeperiods, parallel = true))] +pub fn run_hlc_indicators<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + names: Vec, + timeperiods: Vec, + parallel: bool, +) -> PyResult { + let high_values = high.as_slice()?; + let low_values = low.as_slice()?; + let close_values = close.as_slice()?; + + if high_values.len() != low_values.len() || high_values.len() != close_values.len() { + return Err(PyValueError::new_err( + "high, low, and close must have equal length", + )); + } + + 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()) + } +} + +// --------------------------------------------------------------------------- +// register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(batch_sma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_ema, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_rsi, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_atr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_stoch, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_adx, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(run_close_indicators, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(run_hlc_indicators, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/chunked/mod.rs b/ferro-ta-main/src/chunked/mod.rs new file mode 100644 index 0000000..05e306e --- /dev/null +++ b/ferro-ta-main/src/chunked/mod.rs @@ -0,0 +1,152 @@ +//! 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::*; + +/// Remove the first *overlap* elements from an array. +#[pyfunction] +pub fn trim_overlap<'py>( + py: Python<'py>, + chunk_out: PyReadonlyArray1<'py, f64>, + overlap: usize, +) -> PyResult>> { + let s = chunk_out.as_slice()?; + if overlap > s.len() { + return Err(PyValueError::new_err(format!( + "overlap ({overlap}) must be <= chunk length ({})", + s.len() + ))); + } + let result = ferro_ta_core::chunked::trim_overlap(s, overlap); + Ok(result.into_pyarray(py)) +} + +/// Concatenate a list of trimmed chunk results into a single output array. +#[pyfunction] +pub fn stitch_chunks<'py>( + py: Python<'py>, + chunks: Vec>, +) -> PyResult>> { + 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)) +} + +/// Compute (start, end) index pairs for chunked processing. +#[pyfunction] +pub fn make_chunk_ranges<'py>( + py: Python<'py>, + n: usize, + chunk_size: usize, + overlap: usize, +) -> PyResult>> { + if chunk_size == 0 { + return Err(PyValueError::new_err("chunk_size must be >= 1")); + } + let result = ferro_ta_core::chunked::make_chunk_ranges(n, chunk_size, overlap); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// chunk_apply_close_indicator — stays in PyO3 (dispatches to ferro_ta_core indicators) +// --------------------------------------------------------------------------- + +fn compute_close_indicator( + indicator: &str, + series: &[f64], + timeperiod: usize, +) -> PyResult> { + match indicator { + "SMA" => Ok(ferro_ta_core::overlap::sma(series, timeperiod)), + "EMA" => Ok(ferro_ta_core::overlap::ema(series, timeperiod)), + "RSI" => Ok(ferro_ta_core::momentum::rsi(series, timeperiod)), + _ => Err(PyValueError::new_err(format!( + "chunk_apply_close_indicator does not support indicator '{indicator}'" + ))), + } +} + +/// Run chunked execution for close-only indicators in Rust. +#[pyfunction] +#[pyo3(signature = (series, indicator, timeperiod, chunk_size = 10_000, overlap = 100))] +pub fn chunk_apply_close_indicator<'py>( + py: Python<'py>, + series: PyReadonlyArray1<'py, f64>, + indicator: &str, + timeperiod: usize, + chunk_size: usize, + overlap: usize, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + if chunk_size == 0 { + return Err(PyValueError::new_err("chunk_size must be >= 1")); + } + + let values = series.as_slice()?; + if values.is_empty() { + return Ok(Vec::::new().into_pyarray(py)); + } + + let name = indicator.to_ascii_uppercase(); + let n = values.len(); + let mut stitched: Vec = Vec::with_capacity(n); + let mut start = 0usize; + let mut chunk_index = 0usize; + + loop { + let end = (start + chunk_size + overlap).min(n); + let chunk = &values[start..end]; + let out = compute_close_indicator(name.as_str(), chunk, timeperiod)?; + + let discard = if chunk_index == 0 { 0 } else { overlap }; + if discard > out.len() { + return Err(PyValueError::new_err(format!( + "overlap ({discard}) must be <= chunk output length ({})", + out.len() + ))); + } + stitched.extend_from_slice(&out[discard..]); + + if end >= n { + break; + } + start = end.saturating_sub(overlap); + chunk_index += 1; + } + + if stitched.len() != n { + return Err(PyValueError::new_err(format!( + "internal chunk stitching error: expected output length {n}, got {}", + stitched.len() + ))); + } + + Ok(stitched.into_pyarray(py)) +} + +/// Forward-fill NaN values in a 1-D array. +#[pyfunction] +pub fn forward_fill_nan<'py>( + py: Python<'py>, + values: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let input = values.as_slice()?; + let result = ferro_ta_core::chunked::forward_fill_nan(input); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(trim_overlap, m)?)?; + m.add_function(wrap_pyfunction!(stitch_chunks, m)?)?; + m.add_function(wrap_pyfunction!(make_chunk_ranges, m)?)?; + m.add_function(wrap_pyfunction!(chunk_apply_close_indicator, m)?)?; + m.add_function(wrap_pyfunction!(forward_fill_nan, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/crypto/mod.rs b/ferro-ta-main/src/crypto/mod.rs new file mode 100644 index 0000000..0adde51 --- /dev/null +++ b/ferro-ta-main/src/crypto/mod.rs @@ -0,0 +1,55 @@ +//! 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::*; + +/// Compute the cumulative PnL from funding rate payments. +#[pyfunction] +pub fn funding_cumulative_pnl<'py>( + py: Python<'py>, + position_size: PyReadonlyArray1<'py, f64>, + funding_rate: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let pos = position_size.as_slice()?; + let rate = funding_rate.as_slice()?; + if pos.len() != rate.len() { + return Err(PyValueError::new_err( + "position_size and funding_rate must have the same length", + )); + } + let result = ferro_ta_core::crypto::funding_cumulative_pnl(pos, rate); + Ok(result.into_pyarray(py)) +} + +/// Assign a sequential integer label per bar based on a fixed-size period. +#[pyfunction] +pub fn continuous_bar_labels<'py>( + py: Python<'py>, + n_bars: usize, + period_bars: usize, +) -> PyResult>> { + if period_bars == 0 { + return Err(PyValueError::new_err("period_bars must be >= 1")); + } + let result = ferro_ta_core::crypto::continuous_bar_labels(n_bars, period_bars); + Ok(result.into_pyarray(py)) +} + +/// 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 result = ferro_ta_core::crypto::mark_session_boundaries(ts); + Ok(result.into_pyarray(py)) +} + +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)?)?; + m.add_function(wrap_pyfunction!(mark_session_boundaries, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/cycle/ht_dcperiod.rs b/ferro-ta-main/src/cycle/ht_dcperiod.rs new file mode 100644 index 0000000..377685d --- /dev/null +++ b/ferro-ta-main/src/cycle/ht_dcperiod.rs @@ -0,0 +1,12 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Dominant Cycle Period in bars. +#[pyfunction] +pub fn ht_dcperiod<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let result = ferro_ta_core::cycle::ht_dcperiod(close.as_slice()?); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/cycle/ht_dcphase.rs b/ferro-ta-main/src/cycle/ht_dcphase.rs new file mode 100644 index 0000000..fa11246 --- /dev/null +++ b/ferro-ta-main/src/cycle/ht_dcphase.rs @@ -0,0 +1,12 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Dominant Cycle Phase in degrees. +#[pyfunction] +pub fn ht_dcphase<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let result = ferro_ta_core::cycle::ht_dcphase(close.as_slice()?); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/cycle/ht_phasor.rs b/ferro-ta-main/src/cycle/ht_phasor.rs new file mode 100644 index 0000000..fea25e9 --- /dev/null +++ b/ferro-ta-main/src/cycle/ht_phasor.rs @@ -0,0 +1,13 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Phasor components. Returns (inphase, quadrature) tuple. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn ht_phasor<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let (inphase, quadrature) = ferro_ta_core::cycle::ht_phasor(close.as_slice()?); + Ok((inphase.into_pyarray(py), quadrature.into_pyarray(py))) +} diff --git a/ferro-ta-main/src/cycle/ht_sine.rs b/ferro-ta-main/src/cycle/ht_sine.rs new file mode 100644 index 0000000..fa2756a --- /dev/null +++ b/ferro-ta-main/src/cycle/ht_sine.rs @@ -0,0 +1,13 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform SineWave. Returns (sine, leadsine) where leadsine leads sine by 45°. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn ht_sine<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + 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/ferro-ta-main/src/cycle/ht_trendline.rs b/ferro-ta-main/src/cycle/ht_trendline.rs new file mode 100644 index 0000000..a05d832 --- /dev/null +++ b/ferro-ta-main/src/cycle/ht_trendline.rs @@ -0,0 +1,12 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Instantaneous Trendline (Ehlers). Smooths price over the dominant cycle period. +#[pyfunction] +pub fn ht_trendline<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let result = ferro_ta_core::cycle::ht_trendline(close.as_slice()?); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/cycle/ht_trendmode.rs b/ferro-ta-main/src/cycle/ht_trendmode.rs new file mode 100644 index 0000000..8163103 --- /dev/null +++ b/ferro-ta-main/src/cycle/ht_trendmode.rs @@ -0,0 +1,12 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Trend vs Cycle Mode: 1 = trending, 0 = cycling. +#[pyfunction] +pub fn ht_trendmode<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let result = ferro_ta_core::cycle::ht_trendmode(close.as_slice()?); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/cycle/mod.rs b/ferro-ta-main/src/cycle/mod.rs new file mode 100644 index 0000000..4ba4845 --- /dev/null +++ b/ferro-ta-main/src/cycle/mod.rs @@ -0,0 +1,23 @@ +//! Cycle indicators — Hilbert Transform-based cycle analysis (Ehlers). +//! The shared HT core computation lives in `common.rs`; each indicator has its own file. +//! +//! All functions use a 63-bar lookback period (first 63 values are NaN). + +mod ht_dcperiod; +mod ht_dcphase; +mod ht_phasor; +mod ht_sine; +mod ht_trendline; +mod ht_trendmode; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::ht_trendline::ht_trendline, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_dcperiod::ht_dcperiod, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_dcphase::ht_dcphase, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_phasor::ht_phasor, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_sine::ht_sine, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_trendmode::ht_trendmode, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/extended/mod.rs b/ferro-ta-main/src/extended/mod.rs new file mode 100644 index 0000000..376695d --- /dev/null +++ b/ferro-ta-main/src/extended/mod.rs @@ -0,0 +1,315 @@ +//! Extended Indicators — thin PyO3 wrappers delegating to `ferro_ta_core::extended`. +//! +//! 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 crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +// --------------------------------------------------------------------------- +// VWAP +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, volume, timeperiod = 0))] +pub fn vwap<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + let v = volume.as_slice()?; + validation::validate_equal_length(&[ + (h.len(), "high"), + (lo.len(), "low"), + (c.len(), "close"), + (v.len(), "volume"), + ])?; + let result = ferro_ta_core::extended::vwap(h, lo, c, v, timeperiod); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// VWMA +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (close, volume, timeperiod = 20))] +pub fn vwma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let c = close.as_slice()?; + let v = volume.as_slice()?; + 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)) +} + +// --------------------------------------------------------------------------- +// SUPERTREND +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 7, multiplier = 3.0))] +pub fn supertrend<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + multiplier: f64, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + 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 +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 20))] +pub fn donchian<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + 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), + lower.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// CHOPPINESS_INDEX +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn choppiness_index<'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 h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + 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)) +} + +// --------------------------------------------------------------------------- +// KELTNER_CHANNELS +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 20, atr_period = 10, multiplier = 2.0))] +pub fn keltner_channels<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + atr_period: usize, + multiplier: f64, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + validation::validate_timeperiod(atr_period, "atr_period", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + 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), + lower.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// HULL_MA +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 16))] +pub fn hull_ma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let c = close.as_slice()?; + let result = ferro_ta_core::extended::hull_ma(c, timeperiod); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// CHANDELIER_EXIT +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 22, multiplier = 3.0))] +pub fn chandelier_exit<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + multiplier: f64, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + 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))) +} + +// --------------------------------------------------------------------------- +// ICHIMOKU +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, tenkan_period = 9, kijun_period = 26, senkou_b_period = 52, displacement = 26))] +pub fn ichimoku<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(tenkan_period, "tenkan_period", 1)?; + validation::validate_timeperiod(kijun_period, "kijun_period", 1)?; + validation::validate_timeperiod(senkou_b_period, "senkou_b_period", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + 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), + senkou_a.into_pyarray(py), + senkou_b.into_pyarray(py), + chikou.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// PIVOT_POINTS +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, method = "classic"))] +pub fn pivot_points<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + method: &str, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + + let method_lower = method.to_lowercase(); + 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), + s1.into_pyarray(py), + r2.into_pyarray(py), + s2.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(vwap, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(vwma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(supertrend, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(donchian, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(choppiness_index, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(keltner_channels, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(hull_ma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(chandelier_exit, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(ichimoku, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(pivot_points, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/futures/basis.rs b/ferro-ta-main/src/futures/basis.rs new file mode 100644 index 0000000..2175d5b --- /dev/null +++ b/ferro-ta-main/src/futures/basis.rs @@ -0,0 +1,34 @@ +use pyo3::prelude::*; + +#[pyfunction] +pub fn futures_basis(spot: f64, future: f64) -> PyResult { + Ok(ferro_ta_core::futures::basis::basis(spot, future)) +} + +#[pyfunction] +pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> PyResult { + Ok(ferro_ta_core::futures::basis::annualized_basis( + spot, + future, + time_to_expiry, + )) +} + +#[pyfunction] +pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> PyResult { + Ok(ferro_ta_core::futures::basis::implied_carry_rate( + spot, + future, + time_to_expiry, + )) +} + +#[pyfunction] +pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> PyResult { + Ok(ferro_ta_core::futures::basis::carry_spread( + spot, + future, + rate, + time_to_expiry, + )) +} diff --git a/ferro-ta-main/src/futures/curve.rs b/ferro-ta-main/src/futures/curve.rs new file mode 100644 index 0000000..725a9b3 --- /dev/null +++ b/ferro-ta-main/src/futures/curve.rs @@ -0,0 +1,52 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn calendar_spreads<'py>( + py: Python<'py>, + futures_prices: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + Ok( + ferro_ta_core::futures::curve::calendar_spreads(futures_prices.as_slice()?) + .into_pyarray(py), + ) +} + +#[pyfunction] +pub fn curve_slope<'py>( + tenors: PyReadonlyArray1<'py, f64>, + futures_prices: PyReadonlyArray1<'py, f64>, +) -> PyResult { + let tenors = tenors.as_slice()?; + let futures_prices = futures_prices.as_slice()?; + validation::validate_equal_length(&[ + (tenors.len(), "tenors"), + (futures_prices.len(), "futures_prices"), + ])?; + Ok(ferro_ta_core::futures::curve::curve_slope( + tenors, + futures_prices, + )) +} + +#[pyfunction] +pub fn curve_summary<'py>( + spot: f64, + tenors: PyReadonlyArray1<'py, f64>, + futures_prices: PyReadonlyArray1<'py, f64>, +) -> PyResult<(f64, f64, f64, bool)> { + let tenors = tenors.as_slice()?; + let futures_prices = futures_prices.as_slice()?; + validation::validate_equal_length(&[ + (tenors.len(), "tenors"), + (futures_prices.len(), "futures_prices"), + ])?; + let summary = ferro_ta_core::futures::curve::curve_summary(spot, tenors, futures_prices); + Ok(( + summary.front_basis, + summary.average_basis, + summary.slope, + summary.is_contango, + )) +} diff --git a/ferro-ta-main/src/futures/mod.rs b/ferro-ta-main/src/futures/mod.rs new file mode 100644 index 0000000..b05b813 --- /dev/null +++ b/ferro-ta-main/src/futures/mod.rs @@ -0,0 +1,38 @@ +//! PyO3 wrappers for futures analytics. + +mod basis; +mod curve; +mod roll; +mod synthetic; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!( + self::synthetic::synthetic_forward, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::synthetic::synthetic_spot, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::synthetic::parity_gap, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::basis::futures_basis, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::basis::annualized_basis, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::basis::implied_carry_rate, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::basis::carry_spread, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::roll::weighted_continuous_contract, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::roll::back_adjusted_continuous_contract, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::roll::ratio_adjusted_continuous_contract, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roll::roll_yield, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::curve::calendar_spreads, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::curve::curve_slope, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::curve::curve_summary, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/futures/roll.rs b/ferro-ta-main/src/futures/roll.rs new file mode 100644 index 0000000..03a9819 --- /dev/null +++ b/ferro-ta-main/src/futures/roll.rs @@ -0,0 +1,75 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn weighted_continuous_contract<'py>( + py: Python<'py>, + front: PyReadonlyArray1<'py, f64>, + next: PyReadonlyArray1<'py, f64>, + next_weights: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let front = front.as_slice()?; + let next = next.as_slice()?; + let next_weights = next_weights.as_slice()?; + validation::validate_equal_length(&[ + (front.len(), "front"), + (next.len(), "next"), + (next_weights.len(), "next_weights"), + ])?; + Ok( + ferro_ta_core::futures::roll::weighted_continuous(front, next, next_weights) + .into_pyarray(py), + ) +} + +#[pyfunction] +pub fn back_adjusted_continuous_contract<'py>( + py: Python<'py>, + front: PyReadonlyArray1<'py, f64>, + next: PyReadonlyArray1<'py, f64>, + next_weights: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let front = front.as_slice()?; + let next = next.as_slice()?; + let next_weights = next_weights.as_slice()?; + validation::validate_equal_length(&[ + (front.len(), "front"), + (next.len(), "next"), + (next_weights.len(), "next_weights"), + ])?; + Ok( + ferro_ta_core::futures::roll::back_adjusted_continuous(front, next, next_weights) + .into_pyarray(py), + ) +} + +#[pyfunction] +pub fn ratio_adjusted_continuous_contract<'py>( + py: Python<'py>, + front: PyReadonlyArray1<'py, f64>, + next: PyReadonlyArray1<'py, f64>, + next_weights: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let front = front.as_slice()?; + let next = next.as_slice()?; + let next_weights = next_weights.as_slice()?; + validation::validate_equal_length(&[ + (front.len(), "front"), + (next.len(), "next"), + (next_weights.len(), "next_weights"), + ])?; + Ok( + ferro_ta_core::futures::roll::ratio_adjusted_continuous(front, next, next_weights) + .into_pyarray(py), + ) +} + +#[pyfunction] +pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> PyResult { + Ok(ferro_ta_core::futures::roll::roll_yield( + front_price, + next_price, + time_to_expiry, + )) +} diff --git a/ferro-ta-main/src/futures/synthetic.rs b/ferro-ta-main/src/futures/synthetic.rs new file mode 100644 index 0000000..67a7c35 --- /dev/null +++ b/ferro-ta-main/src/futures/synthetic.rs @@ -0,0 +1,60 @@ +use pyo3::prelude::*; + +#[pyfunction] +pub fn synthetic_forward( + call_price: f64, + put_price: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, +) -> PyResult { + Ok(ferro_ta_core::futures::synthetic::synthetic_forward( + call_price, + put_price, + strike, + rate, + time_to_expiry, + )) +} + +#[pyfunction] +#[pyo3(signature = (call_price, put_price, strike, rate, time_to_expiry, carry = 0.0))] +pub fn synthetic_spot( + call_price: f64, + put_price: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + carry: f64, +) -> PyResult { + Ok(ferro_ta_core::futures::synthetic::synthetic_spot( + call_price, + put_price, + strike, + rate, + carry, + time_to_expiry, + )) +} + +#[pyfunction] +#[pyo3(signature = (call_price, put_price, spot, strike, rate, time_to_expiry, carry = 0.0))] +pub fn parity_gap( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + carry: f64, +) -> PyResult { + Ok(ferro_ta_core::futures::synthetic::parity_gap( + call_price, + put_price, + spot, + strike, + rate, + carry, + time_to_expiry, + )) +} diff --git a/ferro-ta-main/src/lib.rs b/ferro-ta-main/src/lib.rs new file mode 100644 index 0000000..f8e5eee --- /dev/null +++ b/ferro-ta-main/src/lib.rs @@ -0,0 +1,76 @@ +pub mod aggregation; +pub mod alerts; +pub mod attribution; +pub mod backtest; +pub mod batch; +pub mod chunked; +pub mod crypto; +pub mod cycle; +pub mod extended; +pub mod futures; +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 validation; +pub mod volatility; +pub mod volume; + +use pyo3::prelude::*; + +/// ferro_ta — A fast Technical Analysis library powered by Rust. +/// +/// Indicators are organized into modules matching the TA-Lib category structure: +/// - **overlap** : Overlap Studies (SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MACD, BBANDS, SAR, MA, MAVP, MAMA, SAREXT, MACDEXT, …) +/// - **momentum** : Momentum Indicators (RSI, STOCH, ADX, CCI, WILLR, AROON, MFI, …) +/// - **volume** : Volume Indicators (AD, ADOSC, OBV) +/// - **volatility** : Volatility Indicators (ATR, NATR, TRANGE) +/// - **statistic** : Statistic Functions (STDDEV, VAR, LINEARREG, BETA, CORREL, …) +/// - **price_transform**: Price Transformations (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE) +/// - **pattern** : Pattern Recognition (CDLDOJI, CDLENGULFING, CDLHAMMER, …) +/// - **cycle** : Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE) +/// - **batch** : Batch Execution (batch_sma, batch_ema, batch_rsi — 2-D array input) +/// - **streaming** : Streaming Indicators (StreamingSMA, StreamingEMA, … — bar-by-bar PyO3 classes) +/// - **extended** : Extended Indicators (VWAP, SUPERTREND, DONCHIAN, ICHIMOKU, …) +/// - **math_ops** : Rolling Math Operators (rolling_sum, rolling_max, rolling_min, …) +/// - **resampling** : OHLCV resampling helpers (volume_bars, ohlcv_agg) +/// - **aggregation** : Tick/trade aggregation pipeline (aggregate_tick_bars, aggregate_volume_bars_ticks, aggregate_time_bars) +/// - **portfolio** : Portfolio analytics (portfolio_volatility, beta_full, rolling_beta, drawdown_series, correlation_matrix, relative_strength, spread, zscore_series, compose_weighted) +/// - **signals** : Signal helpers (rank_series, top_n_indices, bottom_n_indices) +#[pymodule] +fn _ferro_ta(m: &Bound<'_, PyModule>) -> PyResult<()> { + pyo3_log::init(); + overlap::register(m)?; + momentum::register(m)?; + volume::register(m)?; + volatility::register(m)?; + statistic::register(m)?; + price_transform::register(m)?; + pattern::register(m)?; + cycle::register(m)?; + batch::register(m)?; + streaming::register(m)?; + extended::register(m)?; + math_ops::register(m)?; + options::register(m)?; + futures::register(m)?; + resampling::register(m)?; + aggregation::register(m)?; + portfolio::register(m)?; + signals::register(m)?; + alerts::register(m)?; + crypto::register(m)?; + chunked::register(m)?; + regime::register(m)?; + attribution::register(m)?; + backtest::register(m)?; + Ok(()) +} diff --git a/ferro-ta-main/src/math_ops/mod.rs b/ferro-ta-main/src/math_ops/mod.rs new file mode 100644 index 0000000..6c70f7c --- /dev/null +++ b/ferro-ta-main/src/math_ops/mod.rs @@ -0,0 +1,84 @@ +//! 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 over `timeperiod` bars. +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_sum<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_sum(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Rolling maximum over `timeperiod` bars (O(n) monotonic deque). +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_max<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_max(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Rolling minimum over `timeperiod` bars (O(n) monotonic deque). +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_min<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_min(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Index of rolling maximum over `timeperiod` bars. +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_maxindex<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_maxindex(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Index of rolling minimum over `timeperiod` bars. +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_minindex<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_minindex(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +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)?)?; + m.add_function(pyo3::wrap_pyfunction!(rolling_min, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(rolling_maxindex, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(rolling_minindex, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/momentum/adx.rs b/ferro-ta-main/src/momentum/adx.rs new file mode 100644 index 0000000..0921699 --- /dev/null +++ b/ferro-ta-main/src/momentum/adx.rs @@ -0,0 +1,205 @@ +//! ADX family: PLUS_DM, MINUS_DM, +DI, -DI, DX, ADX, ADXR. +//! Thin wrappers that delegate to ferro_ta_core::momentum. + +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))] +pub fn plus_dm<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + validation::validate_equal_length(&[(highs.len(), "high"), (lows.len(), "low")])?; + let result = ferro_ta_core::momentum::plus_dm(highs, lows, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Minus Directional Movement (Wilder smoothing). +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +pub fn minus_dm<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + validation::validate_equal_length(&[(highs.len(), "high"), (lows.len(), "low")])?; + let result = ferro_ta_core::momentum::minus_dm(highs, lows, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Plus Directional Indicator (Wilder smoothing). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn plus_di<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::plus_di(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Minus Directional Indicator (Wilder smoothing). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn minus_di<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::minus_di(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Directional Movement Index: 100 * |+DI - -DI| / (+DI + -DI). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn dx<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::dx(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Average Directional Movement Index (Wilder smoothing of DX). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn adx<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::adx(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// ADX Rating: (ADX[i] + ADX[i - timeperiod]) / 2. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn adxr<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + 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/ferro-ta-main/src/momentum/apo.rs b/ferro-ta-main/src/momentum/apo.rs new file mode 100644 index 0000000..a7f357c --- /dev/null +++ b/ferro-ta-main/src/momentum/apo.rs @@ -0,0 +1,40 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::ExponentialMovingAverage; +use ta::Next; + +/// Absolute Price Oscillator: fast EMA - slow EMA. +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26))] +pub fn apo<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + if fastperiod >= slowperiod { + return Err(PyValueError::new_err( + "fastperiod must be less than slowperiod", + )); + } + let prices = close.as_slice()?; + let n = prices.len(); + 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, &price) in prices.iter().enumerate() { + let fast = fast_ema.next(price); + let slow = slow_ema.next(price); + if i >= warmup { + result[i] = fast - slow; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/aroon.rs b/ferro-ta-main/src/momentum/aroon.rs new file mode 100644 index 0000000..7ac451a --- /dev/null +++ b/ferro-ta-main/src/momentum/aroon.rs @@ -0,0 +1,87 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Aroon. Returns (aroon_down, aroon_up) tuple. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +#[allow(clippy::type_complexity)] +pub fn aroon<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + let mut aroon_down = vec![f64::NAN; n]; + let mut aroon_up = vec![f64::NAN; n]; + let period_f = timeperiod as f64; + + for i in timeperiod..n { + let window_size = timeperiod + 1; + let start = i + 1 - window_size; + let mut max_val = highs[start]; + let mut min_val = lows[start]; + let mut max_idx = 0usize; + let mut min_idx = 0usize; + for j in 0..window_size { + if highs[start + j] >= max_val { + max_val = highs[start + j]; + max_idx = j; + } + if lows[start + j] <= min_val { + min_val = lows[start + j]; + min_idx = j; + } + } + aroon_up[i] = 100.0 * (max_idx as f64) / period_f; + aroon_down[i] = 100.0 * (min_idx as f64) / period_f; + } + Ok((aroon_down.into_pyarray(py), aroon_up.into_pyarray(py))) +} + +/// Aroon Oscillator: aroon_up - aroon_down. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +pub fn aroonosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + let mut result = vec![f64::NAN; n]; + let period_f = timeperiod as f64; + + #[allow(clippy::needless_range_loop)] + for i in timeperiod..n { + let window_size = timeperiod + 1; + let start = i + 1 - window_size; + let mut max_val = highs[start]; + let mut min_val = lows[start]; + let mut max_idx = 0usize; + let mut min_idx = 0usize; + for j in 0..window_size { + if highs[start + j] >= max_val { + max_val = highs[start + j]; + max_idx = j; + } + if lows[start + j] <= min_val { + min_val = lows[start + j]; + min_idx = j; + } + } + let up = 100.0 * (max_idx as f64) / period_f; + let down = 100.0 * (min_idx as f64) / period_f; + result[i] = up - down; + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/bop.rs b/ferro-ta-main/src/momentum/bop.rs new file mode 100644 index 0000000..f678f90 --- /dev/null +++ b/ferro-ta-main/src/momentum/bop.rs @@ -0,0 +1,35 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Balance Of Power: (close - open) / (high - low). Zero when range is zero. +#[pyfunction] +pub fn bop<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + 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(); + validation::validate_equal_length(&[ + (n, "open"), + (highs.len(), "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let mut result = vec![f64::NAN; n]; + for i in 0..n { + let range = highs[i] - lows[i]; + if range != 0.0 { + result[i] = (closes[i] - opens[i]) / range; + } else { + result[i] = 0.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/cci.rs b/ferro-ta-main/src/momentum/cci.rs new file mode 100644 index 0000000..b579536 --- /dev/null +++ b/ferro-ta-main/src/momentum/cci.rs @@ -0,0 +1,43 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Commodity Channel Index (TA-Lib–compatible): (typical_price - SMA) / (0.015 * MAD). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn cci<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let tp: Vec = highs + .iter() + .zip(lows.iter()) + .zip(closes.iter()) + .map(|((&h, &l), &c)| (h + l + c) / 3.0) + .collect(); + let mut result = vec![f64::NAN; n]; + for i in (timeperiod - 1)..n { + let window = &tp[(i + 1 - timeperiod)..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let mad: f64 = window.iter().map(|&x| (x - mean).abs()).sum::() / timeperiod as f64; + result[i] = if mad != 0.0 { + (tp[i] - mean) / (0.015 * mad) + } else { + 0.0 + }; + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/cmo.rs b/ferro-ta-main/src/momentum/cmo.rs new file mode 100644 index 0000000..2ae4bf1 --- /dev/null +++ b/ferro-ta-main/src/momentum/cmo.rs @@ -0,0 +1,43 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Chande Momentum Oscillator: 100 * (sum of gains - sum of losses) / (sum of gains + sum of losses) over window. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn cmo<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + + if n < timeperiod + 1 { + return Ok(result.into_pyarray(py)); + } + + let changes: Vec = prices.windows(2).map(|w| w[1] - w[0]).collect(); + + #[allow(clippy::needless_range_loop)] + for i in timeperiod..n { + let mut ups = 0.0_f64; + let mut downs = 0.0_f64; + for ch in &changes[(i - timeperiod)..i] { + if *ch > 0.0 { + ups += ch; + } else { + downs -= ch; + } + } + let denom = ups + downs; + result[i] = if denom != 0.0 { + 100.0 * (ups - downs) / denom + } else { + 0.0 + }; + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/mfi.rs b/ferro-ta-main/src/momentum/mfi.rs new file mode 100644 index 0000000..690c2a4 --- /dev/null +++ b/ferro-ta-main/src/momentum/mfi.rs @@ -0,0 +1,31 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Money Flow Index: volume-weighted RSI (typical price * volume). Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (high, low, close, volume, timeperiod = 14))] +pub fn mfi<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: 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()?; + let vols = volume.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + (vols.len(), "volume"), + ])?; + log::debug!("MFI: timeperiod={timeperiod}, n={n}"); + let result = ferro_ta_core::volume::mfi(highs, lows, closes, vols, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/mod.rs b/ferro-ta-main/src/momentum/mod.rs new file mode 100644 index 0000000..7babb82 --- /dev/null +++ b/ferro-ta-main/src/momentum/mod.rs @@ -0,0 +1,54 @@ +//! Momentum indicators — RSI, stochastics, ADX, CCI, etc. +//! Each indicator (or small group) lives in its own file for maintainability. + +mod adx; +mod apo; +mod aroon; +mod bop; +mod cci; +mod cmo; +mod mfi; +mod mom; +mod ppo; +mod roc; +mod rsi; +mod stoch; +mod stochf; +mod stochrsi; +mod trix; +mod ultosc; +mod willr; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::rsi::rsi, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::mom::mom, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roc::roc, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roc::rocp, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roc::rocr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roc::rocr100, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::willr::willr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::aroon::aroon, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::aroon::aroonosc, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cci::cci, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::mfi::mfi, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::bop::bop, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::stochf::stochf, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::stoch::stoch, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::stochrsi::stochrsi, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::apo::apo, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ppo::ppo, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cmo::cmo, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::plus_dm, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::minus_dm, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::plus_di, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::minus_di, m)?)?; + 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/ferro-ta-main/src/momentum/mom.rs b/ferro-ta-main/src/momentum/mom.rs new file mode 100644 index 0000000..55ee9f4 --- /dev/null +++ b/ferro-ta-main/src/momentum/mom.rs @@ -0,0 +1,21 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Momentum: close[i] - close[i - timeperiod]. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn mom<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + result[i] = prices[i] - prices[i - timeperiod]; + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/ppo.rs b/ferro-ta-main/src/momentum/ppo.rs new file mode 100644 index 0000000..7f1bde2 --- /dev/null +++ b/ferro-ta-main/src/momentum/ppo.rs @@ -0,0 +1,52 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::PercentagePriceOscillator; +use ta::Next; + +/// Percentage Price Oscillator. Returns (ppo_line, signal_line, histogram). +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))] +#[allow(clippy::type_complexity)] +pub fn ppo<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + 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( + "fastperiod must be less than slowperiod", + )); + } + let prices = close.as_slice()?; + let n = prices.len(); + let mut indicator = PercentagePriceOscillator::new(fastperiod, slowperiod, signalperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let warmup = slowperiod + signalperiod - 2; + let mut ppo_line = vec![f64::NAN; n]; + let mut signal_line = vec![f64::NAN; n]; + let mut hist = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let out = indicator.next(price); + if i >= warmup { + ppo_line[i] = out.ppo; + signal_line[i] = out.signal; + hist[i] = out.histogram; + } + } + Ok(( + ppo_line.into_pyarray(py), + signal_line.into_pyarray(py), + hist.into_pyarray(py), + )) +} diff --git a/ferro-ta-main/src/momentum/roc.rs b/ferro-ta-main/src/momentum/roc.rs new file mode 100644 index 0000000..289439b --- /dev/null +++ b/ferro-ta-main/src/momentum/roc.rs @@ -0,0 +1,92 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::RateOfChange; +use ta::Next; + +/// Rate of Change: (price - prev) / prev * 100. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn roc<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut indicator = + RateOfChange::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let val = indicator.next(price); + if i >= timeperiod { + result[i] = val; + } + } + Ok(result.into_pyarray(py)) +} + +/// Rate of Change Percentage: (price - prev) / prev. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn rocp<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + let prev = prices[i - timeperiod]; + if prev != 0.0 { + result[i] = (prices[i] - prev) / prev; + } + } + Ok(result.into_pyarray(py)) +} + +/// Rate of Change Ratio: price / prev. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn rocr<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + let prev = prices[i - timeperiod]; + if prev != 0.0 { + result[i] = prices[i] / prev; + } + } + Ok(result.into_pyarray(py)) +} + +/// Rate of Change Ratio × 100: (price / prev) * 100. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn rocr100<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + let prev = prices[i - timeperiod]; + if prev != 0.0 { + result[i] = (prices[i] / prev) * 100.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/rsi.rs b/ferro-ta-main/src/momentum/rsi.rs new file mode 100644 index 0000000..8582282 --- /dev/null +++ b/ferro-ta-main/src/momentum/rsi.rs @@ -0,0 +1,19 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Relative Strength Index. Uses TA-Lib–compatible Wilder smoothing seed: +/// seed = SMA of first `timeperiod` gains (or losses), then Wilder EMA. +/// Returns NaN for the first `timeperiod` bars. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn rsi<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let result = ferro_ta_core::momentum::rsi(prices, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/stoch.rs b/ferro-ta-main/src/momentum/stoch.rs new file mode 100644 index 0000000..d3ca4c7 --- /dev/null +++ b/ferro-ta-main/src/momentum/stoch.rs @@ -0,0 +1,40 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Slow Stochastic. Returns (slowk, slowd). Matches TA-Lib: Fast %K raw, Slow %K = SMA(fast %K, slowk_period), Slow %D = SMA(slow %K, slowd_period). +/// Uses O(n) sliding max/min via monotonic deques. +#[pyfunction] +#[pyo3(signature = (high, low, close, fastk_period = 5, slowk_period = 3, slowd_period = 3))] +#[allow(clippy::type_complexity)] +pub fn stoch<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(fastk_period, "fastk_period", 1)?; + validation::validate_timeperiod(slowk_period, "slowk_period", 1)?; + validation::validate_timeperiod(slowd_period, "slowd_period", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let (slowk, slowd) = ferro_ta_core::momentum::stoch( + highs, + lows, + closes, + fastk_period, + slowk_period, + slowd_period, + ); + Ok((slowk.into_pyarray(py), slowd.into_pyarray(py))) +} diff --git a/ferro-ta-main/src/momentum/stochf.rs b/ferro-ta-main/src/momentum/stochf.rs new file mode 100644 index 0000000..24728fe --- /dev/null +++ b/ferro-ta-main/src/momentum/stochf.rs @@ -0,0 +1,62 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::{ExponentialMovingAverage, FastStochastic}; +use ta::{DataItem, Next}; + +/// Fast Stochastic. Returns (fastk, fastd). %K from high-low range; %D is EMA of %K. +#[pyfunction] +#[pyo3(signature = (high, low, close, fastk_period = 5, fastd_period = 3))] +#[allow(clippy::type_complexity)] +pub fn stochf<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + fastk_period: usize, + fastd_period: usize, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(fastk_period, "fastk_period", 1)?; + validation::validate_timeperiod(fastd_period, "fastd_period", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + + let mut fast_stoch = + FastStochastic::new(fastk_period).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut d_ema = ExponentialMovingAverage::new(fastd_period) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let warmup_k = fastk_period - 1; + let warmup_d = warmup_k + fastd_period - 1; + + let mut fastk = vec![f64::NAN; n]; + let mut fastd = vec![f64::NAN; n]; + + for (i, ((&h, &l), &c)) in highs.iter().zip(lows.iter()).zip(closes.iter()).enumerate() { + let bar = DataItem::builder() + .high(h) + .low(l) + .close(c) + .open(c) + .volume(0.0) + .build() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let k = fast_stoch.next(&bar); + if i >= warmup_k { + fastk[i] = k; + let d = d_ema.next(k); + if i >= warmup_d { + fastd[i] = d; + } + } + } + Ok((fastk.into_pyarray(py), fastd.into_pyarray(py))) +} diff --git a/ferro-ta-main/src/momentum/stochrsi.rs b/ferro-ta-main/src/momentum/stochrsi.rs new file mode 100644 index 0000000..3e974ce --- /dev/null +++ b/ferro-ta-main/src/momentum/stochrsi.rs @@ -0,0 +1,106 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +fn compute_rsi_talib(prices: &[f64], period: usize) -> Vec { + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + if n <= period || period == 0 { + return result; + } + let mut avg_gain = 0.0_f64; + let mut avg_loss = 0.0_f64; + for i in 1..=period { + let delta = prices[i] - prices[i - 1]; + if delta > 0.0 { + avg_gain += delta; + } else { + avg_loss += -delta; + } + } + avg_gain /= period as f64; + avg_loss /= period as f64; + let rs = if avg_loss == 0.0 { + f64::MAX + } else { + avg_gain / avg_loss + }; + result[period] = 100.0 - 100.0 / (1.0 + rs); + let period_f = period as f64; + for i in (period + 1)..n { + let delta = prices[i] - prices[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 + }; + result[i] = 100.0 - 100.0 / (1.0 + rs); + } + result +} + +/// Stochastic RSI (TA-Lib–compatible): stochastic applied to RSI. Returns (fastk, fastd). +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14, fastk_period = 5, fastd_period = 3))] +#[allow(clippy::type_complexity)] +pub fn stochrsi<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + fastk_period: usize, + fastd_period: usize, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + validation::validate_timeperiod(fastk_period, "fastk_period", 1)?; + validation::validate_timeperiod(fastd_period, "fastd_period", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let rsi_vals = compute_rsi_talib(prices, timeperiod); + + let rsi_warmup = timeperiod; + let k_warmup = rsi_warmup + fastk_period - 1; + let d_warmup = k_warmup + fastd_period - 1; + + let mut fastk = vec![f64::NAN; n]; + let mut fastd = vec![f64::NAN; n]; + + for i in k_warmup..n { + if rsi_vals[i].is_nan() { + continue; + } + let start = i + 1 - fastk_period; + if (start..=i).any(|j| rsi_vals[j].is_nan()) { + continue; + } + let mx = rsi_vals[start..=i] + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let mn = rsi_vals[start..=i] + .iter() + .cloned() + .fold(f64::INFINITY, f64::min); + fastk[i] = if mx != mn { + 100.0 * (rsi_vals[i] - mn) / (mx - mn) + } else { + 50.0 + }; + } + + for i in d_warmup..n { + let start = i + 1 - fastd_period; + let window = &fastk[start..=i]; + if window.iter().all(|v| !v.is_nan()) { + fastd[i] = window.iter().sum::() / fastd_period as f64; + } + } + Ok((fastk.into_pyarray(py), fastd.into_pyarray(py))) +} diff --git a/ferro-ta-main/src/momentum/trix.rs b/ferro-ta-main/src/momentum/trix.rs new file mode 100644 index 0000000..2cbcb08 --- /dev/null +++ b/ferro-ta-main/src/momentum/trix.rs @@ -0,0 +1,51 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::ExponentialMovingAverage; +use ta::Next; + +/// TRIX: 1-period rate of change of triple-smoothed EMA. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn trix<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut ema1 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema2 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema3 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let warmup = 3 * (timeperiod - 1); + let mut ema3_vals = vec![f64::NAN; n]; + let mut result = vec![f64::NAN; n]; + + for (i, &price) in prices.iter().enumerate() { + let v1 = ema1.next(price); + if i >= timeperiod - 1 { + let v2 = ema2.next(v1); + if i >= 2 * (timeperiod - 1) { + let v3 = ema3.next(v2); + if i >= warmup { + ema3_vals[i] = v3; + } + } + } + } + + for i in (warmup + 1)..n { + let prev = ema3_vals[i - 1]; + if !ema3_vals[i].is_nan() && !prev.is_nan() && prev != 0.0 { + result[i] = (ema3_vals[i] - prev) / prev * 100.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/ultosc.rs b/ferro-ta-main/src/momentum/ultosc.rs new file mode 100644 index 0000000..b3cef76 --- /dev/null +++ b/ferro-ta-main/src/momentum/ultosc.rs @@ -0,0 +1,73 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Ultimate Oscillator: weighted sum of buying pressure over three periods (7, 14, 28). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod1 = 7, timeperiod2 = 14, timeperiod3 = 28))] +pub fn ultosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod1: usize, + timeperiod2: usize, + timeperiod3: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod1, "timeperiod1", 1)?; + validation::validate_timeperiod(timeperiod2, "timeperiod2", 1)?; + validation::validate_timeperiod(timeperiod3, "timeperiod3", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + + let max_period = timeperiod1.max(timeperiod2).max(timeperiod3); + let mut result = vec![f64::NAN; n]; + + let mut bp = vec![0.0_f64; n]; + let mut tr = vec![0.0_f64; n]; + for i in 1..n { + let true_low = lows[i].min(closes[i - 1]); + let true_high = highs[i].max(closes[i - 1]); + bp[i] = closes[i] - true_low; + tr[i] = true_high - true_low; + } + + for i in max_period..n { + let raw1 = { + let sum_bp: f64 = bp[(i + 1 - timeperiod1)..=i].iter().sum(); + let sum_tr: f64 = tr[(i + 1 - timeperiod1)..=i].iter().sum(); + if sum_tr != 0.0 { + sum_bp / sum_tr + } else { + 0.0 + } + }; + let raw2 = { + let sum_bp: f64 = bp[(i + 1 - timeperiod2)..=i].iter().sum(); + let sum_tr: f64 = tr[(i + 1 - timeperiod2)..=i].iter().sum(); + if sum_tr != 0.0 { + sum_bp / sum_tr + } else { + 0.0 + } + }; + let raw3 = { + let sum_bp: f64 = bp[(i + 1 - timeperiod3)..=i].iter().sum(); + let sum_tr: f64 = tr[(i + 1 - timeperiod3)..=i].iter().sum(); + if sum_tr != 0.0 { + sum_bp / sum_tr + } else { + 0.0 + } + }; + result[i] = 100.0 * (4.0 * raw1 + 2.0 * raw2 + raw3) / 7.0; + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/momentum/willr.rs b/ferro-ta-main/src/momentum/willr.rs new file mode 100644 index 0000000..cce8948 --- /dev/null +++ b/ferro-ta-main/src/momentum/willr.rs @@ -0,0 +1,44 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::{Maximum, Minimum}; +use ta::Next; + +/// Williams' %R: -100 * (highest high - close) / (highest high - lowest low) over the window. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn willr<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let mut max_ind = Maximum::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut min_ind = Minimum::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, ((&h, &l), &c)) in highs.iter().zip(lows.iter()).zip(closes.iter()).enumerate() { + let highest = max_ind.next(h); + let lowest = min_ind.next(l); + if i + 1 >= timeperiod { + let range = highest - lowest; + if range != 0.0 { + result[i] = -100.0 * (highest - c) / range; + } else { + result[i] = -50.0; + } + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/options/american.rs b/ferro-ta-main/src/options/american.rs new file mode 100644 index 0000000..34e5f68 --- /dev/null +++ b/ferro-ta-main/src/options/american.rs @@ -0,0 +1,127 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +use ferro_ta_core::options::american::{ + american_price_baw as core_american_price, + early_exercise_premium as core_early_exercise_premium, +}; + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn american_price( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + carry: f64, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + Ok(core_american_price( + underlying, + strike, + rate, + carry, + time_to_expiry, + volatility, + kind, + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn american_price_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + carry: Option>, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let tte = time_to_expiry.as_slice()?; + let vol = volatility.as_slice()?; + let n = underlying.len(); + let carry_vec = match carry { + Some(arr) => arr.as_slice()?.to_vec(), + None => vec![0.0; n], + }; + let out: Vec = underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(tte.iter()) + .zip(vol.iter()) + .zip(carry_vec.iter()) + .map(|(((((&u, &k), &r), &t), &v), &c)| core_american_price(u, k, r, c, t, v, kind)) + .collect(); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn early_exercise_premium( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + carry: f64, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + Ok(core_early_exercise_premium( + underlying, + strike, + rate, + carry, + time_to_expiry, + volatility, + kind, + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn early_exercise_premium_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + carry: Option>, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let tte = time_to_expiry.as_slice()?; + let vol = volatility.as_slice()?; + let n = underlying.len(); + let carry_vec = match carry { + Some(arr) => arr.as_slice()?.to_vec(), + None => vec![0.0; n], + }; + let out: Vec = underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(tte.iter()) + .zip(vol.iter()) + .zip(carry_vec.iter()) + .map(|(((((&u, &k), &r), &t), &v), &c)| core_early_exercise_premium(u, k, r, c, t, v, kind)) + .collect(); + Ok(out.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/options/chain.rs b/ferro-ta-main/src/options/chain.rs new file mode 100644 index 0000000..c2d055b --- /dev/null +++ b/ferro-ta-main/src/options/chain.rs @@ -0,0 +1,64 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (strikes, reference_price, option_type = "call"))] +pub fn moneyness_labels<'py>( + py: Python<'py>, + strikes: PyReadonlyArray1<'py, f64>, + reference_price: f64, + option_type: &str, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let strikes = strikes.as_slice()?; + let labels = ferro_ta_core::options::chain::label_moneyness(strikes, reference_price, kind); + Ok(labels.into_pyarray(py)) +} + +#[pyfunction] +pub fn select_strike_offset<'py>( + strikes: PyReadonlyArray1<'py, f64>, + reference_price: f64, + offset: isize, +) -> PyResult> { + Ok(ferro_ta_core::options::chain::select_strike_by_offset( + strikes.as_slice()?, + reference_price, + offset, + )) +} + +#[pyfunction] +#[pyo3(signature = (strikes, vols, reference_price, time_to_expiry, target_delta, option_type = "call", model = "bsm", rate = 0.0, carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn select_strike_delta<'py>( + strikes: PyReadonlyArray1<'py, f64>, + vols: PyReadonlyArray1<'py, f64>, + reference_price: f64, + time_to_expiry: f64, + target_delta: f64, + option_type: &str, + model: &str, + rate: f64, + carry: f64, +) -> PyResult> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let strikes = strikes.as_slice()?; + let vols = vols.as_slice()?; + validation::validate_equal_length(&[(strikes.len(), "strikes"), (vols.len(), "vols")])?; + Ok(ferro_ta_core::options::chain::select_strike_by_delta( + strikes, + vols, + ferro_ta_core::options::ChainGreeksContext { + model, + reference_price, + rate, + carry, + time_to_expiry, + kind, + }, + target_delta, + )) +} diff --git a/ferro-ta-main/src/options/digital.rs b/ferro-ta-main/src/options/digital.rs new file mode 100644 index 0000000..75c84c7 --- /dev/null +++ b/ferro-ta-main/src/options/digital.rs @@ -0,0 +1,164 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use ferro_ta_core::options::digital::{ + digital_greeks as core_digital_greeks, digital_price as core_digital_price, DigitalKind, +}; + +fn parse_digital_kind(s: &str) -> PyResult { + match s.to_ascii_lowercase().replace('-', "_").as_str() { + "cash_or_nothing" | "cash" => Ok(DigitalKind::CashOrNothing), + "asset_or_nothing" | "asset" => Ok(DigitalKind::AssetOrNothing), + _ => Err(PyValueError::new_err( + "digital_type must be 'cash_or_nothing' or 'asset_or_nothing'", + )), + } +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn digital_price( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + digital_type: &str, + carry: f64, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + let dkind = parse_digital_kind(digital_type)?; + Ok(core_digital_price( + underlying, + strike, + rate, + carry, + time_to_expiry, + volatility, + kind, + dkind, + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn digital_price_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + digital_type: &str, + carry: Option>, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let dkind = parse_digital_kind(digital_type)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let tte = time_to_expiry.as_slice()?; + let vol = volatility.as_slice()?; + let n = underlying.len(); + let carry_vec = match carry { + Some(arr) => arr.as_slice()?.to_vec(), + None => vec![0.0; n], + }; + let out: Vec = underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(tte.iter()) + .zip(vol.iter()) + .zip(carry_vec.iter()) + .map(|(((((&u, &k), &r), &t), &v), &c)| core_digital_price(u, k, r, c, t, v, kind, dkind)) + .collect(); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn digital_greeks( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + digital_type: &str, + carry: f64, +) -> PyResult<(f64, f64, f64)> { + let kind = super::parse_option_kind(option_type)?; + let dkind = parse_digital_kind(digital_type)?; + Ok(core_digital_greeks( + underlying, + strike, + rate, + carry, + time_to_expiry, + volatility, + kind, + dkind, + )) +} + +type GreekTriple<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn digital_greeks_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + digital_type: &str, + carry: Option>, +) -> PyResult> { + let kind = super::parse_option_kind(option_type)?; + let dkind = parse_digital_kind(digital_type)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let tte = time_to_expiry.as_slice()?; + let vol = volatility.as_slice()?; + let n = underlying.len(); + let carry_vec = match carry { + Some(arr) => arr.as_slice()?.to_vec(), + None => vec![0.0; n], + }; + let mut delta = Vec::with_capacity(n); + let mut gamma = Vec::with_capacity(n); + let mut vega = Vec::with_capacity(n); + for (((((&u, &k), &r), &t), &v), &c) in underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(tte.iter()) + .zip(vol.iter()) + .zip(carry_vec.iter()) + { + let (d, g, ve) = core_digital_greeks(u, k, r, c, t, v, kind, dkind); + delta.push(d); + gamma.push(g); + vega.push(ve); + } + Ok(( + delta.into_pyarray(py), + gamma.into_pyarray(py), + vega.into_pyarray(py), + )) +} diff --git a/ferro-ta-main/src/options/greeks.rs b/ferro-ta-main/src/options/greeks.rs new file mode 100644 index 0000000..7b7f06a --- /dev/null +++ b/ferro-ta-main/src/options/greeks.rs @@ -0,0 +1,242 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +type ExtendedGreekArrays<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +type GreekArrays<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn option_greeks( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + model: &str, + carry: f64, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let greeks = + ferro_ta_core::options::greeks::model_greeks(ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model, + underlying, + strike, + rate, + carry, + time_to_expiry, + kind, + }, + volatility, + }); + Ok(( + greeks.delta, + greeks.gamma, + greeks.vega, + greeks.theta, + greeks.rho, + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn option_greeks_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + model: &str, + carry: Option>, +) -> PyResult> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let volatility = volatility.as_slice()?; + let carry_vec = match carry { + Some(array) => array.as_slice()?.to_vec(), + None => vec![0.0; underlying.len()], + }; + validation::validate_equal_length(&[ + (underlying.len(), "underlying"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (volatility.len(), "volatility"), + (carry_vec.len(), "carry"), + ])?; + + let mut delta = Vec::with_capacity(underlying.len()); + let mut gamma = Vec::with_capacity(underlying.len()); + let mut vega = Vec::with_capacity(underlying.len()); + let mut theta = Vec::with_capacity(underlying.len()); + let mut rho = Vec::with_capacity(underlying.len()); + for (((((&u, &k), &r), &t), &vol), &c) in underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(volatility.iter()) + .zip(carry_vec.iter()) + { + let g = ferro_ta_core::options::greeks::model_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model, + underlying: u, + strike: k, + rate: r, + carry: c, + time_to_expiry: t, + kind, + }, + volatility: vol, + }, + ); + delta.push(g.delta); + gamma.push(g.gamma); + vega.push(g.vega); + theta.push(g.theta); + rho.push(g.rho); + } + + Ok(( + delta.into_pyarray(py), + gamma.into_pyarray(py), + vega.into_pyarray(py), + theta.into_pyarray(py), + rho.into_pyarray(py), + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn extended_greeks( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + model: &str, + carry: f64, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let eg = ferro_ta_core::options::greeks::model_extended_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model, + underlying, + strike, + rate, + carry, + time_to_expiry, + kind, + }, + volatility, + }, + ); + Ok((eg.vanna, eg.volga, eg.charm, eg.speed, eg.color)) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn extended_greeks_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + model: &str, + carry: Option>, +) -> PyResult> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let volatility = volatility.as_slice()?; + let carry_vec = match carry { + Some(array) => array.as_slice()?.to_vec(), + None => vec![0.0; underlying.len()], + }; + validation::validate_equal_length(&[ + (underlying.len(), "underlying"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (volatility.len(), "volatility"), + (carry_vec.len(), "carry"), + ])?; + + let mut vanna = Vec::with_capacity(underlying.len()); + let mut volga = Vec::with_capacity(underlying.len()); + let mut charm = Vec::with_capacity(underlying.len()); + let mut speed = Vec::with_capacity(underlying.len()); + let mut color = Vec::with_capacity(underlying.len()); + for (((((&u, &k), &r), &t), &vol), &c) in underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(volatility.iter()) + .zip(carry_vec.iter()) + { + let eg = ferro_ta_core::options::greeks::model_extended_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model, + underlying: u, + strike: k, + rate: r, + carry: c, + time_to_expiry: t, + kind, + }, + volatility: vol, + }, + ); + vanna.push(eg.vanna); + volga.push(eg.volga); + charm.push(eg.charm); + speed.push(eg.speed); + color.push(eg.color); + } + + Ok(( + vanna.into_pyarray(py), + volga.into_pyarray(py), + charm.into_pyarray(py), + speed.into_pyarray(py), + color.into_pyarray(py), + )) +} diff --git a/ferro-ta-main/src/options/iv.rs b/ferro-ta-main/src/options/iv.rs new file mode 100644 index 0000000..d5bba3d --- /dev/null +++ b/ferro-ta-main/src/options/iv.rs @@ -0,0 +1,149 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (price, underlying, strike, rate, time_to_expiry, option_type = "call", model = "bsm", carry = 0.0, initial_guess = 0.2, tolerance = 1e-8, max_iterations = 100))] +#[allow(clippy::too_many_arguments)] +pub fn implied_volatility( + price: f64, + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + option_type: &str, + model: &str, + carry: f64, + initial_guess: f64, + tolerance: f64, + max_iterations: usize, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + Ok(ferro_ta_core::options::iv::implied_volatility( + ferro_ta_core::options::OptionContract { + model, + underlying, + strike, + rate, + carry, + time_to_expiry, + kind, + }, + price, + ferro_ta_core::options::IvSolverConfig { + initial_guess, + tolerance, + max_iterations, + }, + )) +} + +#[pyfunction] +#[pyo3(signature = (price, underlying, strike, rate, time_to_expiry, option_type = "call", model = "bsm", carry = None, initial_guess = None, tolerance = 1e-8, max_iterations = 100))] +#[allow(clippy::too_many_arguments)] +pub fn implied_volatility_batch<'py>( + py: Python<'py>, + price: PyReadonlyArray1<'py, f64>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + option_type: &str, + model: &str, + carry: Option>, + initial_guess: Option>, + tolerance: f64, + max_iterations: usize, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let price = price.as_slice()?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let carry_vec = match carry { + Some(array) => array.as_slice()?.to_vec(), + None => vec![0.0; price.len()], + }; + let guess_vec = match initial_guess { + Some(array) => array.as_slice()?.to_vec(), + None => vec![0.2; price.len()], + }; + validation::validate_equal_length(&[ + (price.len(), "price"), + (underlying.len(), "underlying"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (carry_vec.len(), "carry"), + (guess_vec.len(), "initial_guess"), + ])?; + + let out: Vec = price + .iter() + .zip(underlying.iter()) + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(carry_vec.iter()) + .zip(guess_vec.iter()) + .map(|((((((&p, &u), &k), &r), &t), &c), &guess)| { + ferro_ta_core::options::iv::implied_volatility( + ferro_ta_core::options::OptionContract { + model, + underlying: u, + strike: k, + rate: r, + carry: c, + time_to_expiry: t, + kind, + }, + p, + ferro_ta_core::options::IvSolverConfig { + initial_guess: guess, + tolerance, + max_iterations, + }, + ) + }) + .collect(); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (iv_series, window = 252))] +pub fn iv_rank<'py>( + py: Python<'py>, + iv_series: PyReadonlyArray1<'py, f64>, + window: i64, +) -> PyResult>> { + let window = validation::parse_timeperiod(window, "window", 1)?; + let out = ferro_ta_core::options::iv::iv_rank(iv_series.as_slice()?, window); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (iv_series, window = 252))] +pub fn iv_percentile<'py>( + py: Python<'py>, + iv_series: PyReadonlyArray1<'py, f64>, + window: i64, +) -> PyResult>> { + let window = validation::parse_timeperiod(window, "window", 1)?; + let out = ferro_ta_core::options::iv::iv_percentile(iv_series.as_slice()?, window); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (iv_series, window = 252))] +pub fn iv_zscore<'py>( + py: Python<'py>, + iv_series: PyReadonlyArray1<'py, f64>, + window: i64, +) -> PyResult>> { + let window = validation::parse_timeperiod(window, "window", 1)?; + let out = ferro_ta_core::options::iv::iv_zscore(iv_series.as_slice()?, window); + Ok(out.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/options/mod.rs b/ferro-ta-main/src/options/mod.rs new file mode 100644 index 0000000..625b1d0 --- /dev/null +++ b/ferro-ta-main/src/options/mod.rs @@ -0,0 +1,148 @@ +//! PyO3 wrappers for options analytics. + +mod american; +mod chain; +mod digital; +mod greeks; +mod iv; +mod payoff; +mod pricing; +mod realized_vol; +mod surface; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +pub(crate) fn parse_option_kind(option_type: &str) -> PyResult { + match option_type.to_ascii_lowercase().as_str() { + "call" | "c" => Ok(ferro_ta_core::options::OptionKind::Call), + "put" | "p" => Ok(ferro_ta_core::options::OptionKind::Put), + _ => Err(PyValueError::new_err(format!( + "option_type must be 'call' or 'put', got {option_type}" + ))), + } +} + +pub(crate) fn parse_pricing_model(model: &str) -> PyResult { + match model.to_ascii_lowercase().as_str() { + "bsm" | "black_scholes" | "black-scholes" | "blackscholes" => { + Ok(ferro_ta_core::options::PricingModel::BlackScholes) + } + "black76" | "black_76" | "black-76" => Ok(ferro_ta_core::options::PricingModel::Black76), + _ => Err(PyValueError::new_err(format!( + "model must be one of 'bsm'/'black_scholes' or 'black76', got {model}" + ))), + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::pricing::bsm_price, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::pricing::black76_price, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::pricing::bsm_price_batch, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::pricing::black76_price_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::pricing::put_call_parity_deviation, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::greeks::option_greeks, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::greeks::option_greeks_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::greeks::extended_greeks, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::greeks::extended_greeks_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::iv::implied_volatility, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::iv::implied_volatility_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_rank, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_percentile, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_zscore, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::surface::smile_metrics, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::surface::term_structure_slope, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::surface::expected_move, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::chain::moneyness_labels, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::chain::select_strike_offset, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::chain::select_strike_delta, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::strategy_payoff_dense, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::strategy_payoff_legs, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::aggregate_greeks_dense, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::aggregate_greeks_legs, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::strategy_value_dense, + m + )?)?; + // Digital options + m.add_function(pyo3::wrap_pyfunction!(self::digital::digital_price, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::digital::digital_price_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::digital::digital_greeks, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::digital::digital_greeks_batch, + m + )?)?; + // American options + m.add_function(pyo3::wrap_pyfunction!(self::american::american_price, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::american::american_price_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::american::early_exercise_premium, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::american::early_exercise_premium_batch, + m + )?)?; + // Historical volatility estimators + vol cone + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::close_to_close_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::parkinson_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::garman_klass_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::rogers_satchell_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::yang_zhang_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::realized_vol::vol_cone, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/options/payoff.rs b/ferro-ta-main/src/options/payoff.rs new file mode 100644 index 0000000..5183027 --- /dev/null +++ b/ferro-ta-main/src/options/payoff.rs @@ -0,0 +1,495 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyTuple}; + +#[derive(Clone, Copy)] +enum Instrument { + Option, + Future, + Stock, +} + +#[derive(Clone, Copy)] +enum Side { + Long, + Short, +} + +#[derive(Clone, Copy)] +enum OptionType { + Call, + Put, +} + +impl Side { + fn sign(self) -> f64 { + match self { + Side::Long => 1.0, + Side::Short => -1.0, + } + } +} + +fn parse_instrument(v: i64) -> PyResult { + match v { + 0 => Ok(Instrument::Option), + 1 => Ok(Instrument::Future), + 2 => Ok(Instrument::Stock), + _ => Err(PyValueError::new_err( + "instrument must be 0 (option), 1 (future), or 2 (stock)", + )), + } +} + +fn parse_side(v: i64) -> PyResult { + match v { + 1 => Ok(Side::Long), + -1 => Ok(Side::Short), + _ => Err(PyValueError::new_err("side must be 1 (long) or -1 (short)")), + } +} + +fn parse_option_type(v: i64) -> PyResult { + match v { + 1 => Ok(OptionType::Call), + -1 => Ok(OptionType::Put), + _ => Err(PyValueError::new_err( + "option_type must be 1 (call) or -1 (put)", + )), + } +} + +fn parse_instrument_label(v: &str) -> PyResult { + match v.to_ascii_lowercase().as_str() { + "option" => Ok(Instrument::Option), + "future" => Ok(Instrument::Future), + "stock" => Ok(Instrument::Stock), + _ => Err(PyValueError::new_err( + "instrument must be 'option', 'future', or 'stock'", + )), + } +} + +fn parse_side_label(v: &str) -> PyResult { + match v.to_ascii_lowercase().as_str() { + "long" => Ok(Side::Long), + "short" => Ok(Side::Short), + _ => Err(PyValueError::new_err("side must be 'long' or 'short'")), + } +} + +fn parse_option_type_label(v: &str) -> PyResult { + match v.to_ascii_lowercase().as_str() { + "call" => Ok(OptionType::Call), + "put" => Ok(OptionType::Put), + _ => Err(PyValueError::new_err("option_type must be 'call' or 'put'")), + } +} + +fn leg_attr_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult { + let value = leg + .getattr(name) + .map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?; + value.extract::().map_err(|_| { + PyValueError::new_err(format!( + "leg field '{name}' has invalid type; expected string" + )) + }) +} + +fn leg_attr_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult { + let value = leg + .getattr(name) + .map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?; + value.extract::().map_err(|_| { + PyValueError::new_err(format!( + "leg field '{name}' has invalid type; expected float" + )) + }) +} + +fn leg_attr_optional_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult> { + let value = leg + .getattr(name) + .map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?; + if value.is_none() { + return Ok(None); + } + value.extract::().map(Some).map_err(|_| { + PyValueError::new_err(format!( + "leg field '{name}' has invalid type; expected string or None" + )) + }) +} + +fn leg_attr_optional_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult> { + let value = leg + .getattr(name) + .map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?; + if value.is_none() { + return Ok(None); + } + value.extract::().map(Some).map_err(|_| { + PyValueError::new_err(format!( + "leg field '{name}' has invalid type; expected float or None" + )) + }) +} + +/// Compute aggregate strategy payoff over a spot grid. +/// +/// Encoded arrays (same length = n_legs): +/// - `instruments`: 0=option, 1=future +/// - `sides`: 1=long, -1=short +/// - `option_types`: 1=call, -1=put (ignored for futures) +/// - `strikes`: strike for options, ignored for futures +/// - `premiums`: premium for options, ignored for futures +/// - `entry_prices`: entry price for futures, ignored for options +/// - `quantities`, `multipliers`: applied to both instruments +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub fn strategy_payoff_dense<'py>( + py: Python<'py>, + spot_grid: PyReadonlyArray1<'py, f64>, + instruments: PyReadonlyArray1<'py, i64>, + sides: PyReadonlyArray1<'py, i64>, + option_types: PyReadonlyArray1<'py, i64>, + strikes: PyReadonlyArray1<'py, f64>, + premiums: PyReadonlyArray1<'py, f64>, + entry_prices: PyReadonlyArray1<'py, f64>, + quantities: PyReadonlyArray1<'py, f64>, + multipliers: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let grid = spot_grid.as_slice()?; + let inst = instruments.as_slice()?; + let side = sides.as_slice()?; + let opt_t = option_types.as_slice()?; + let strike = strikes.as_slice()?; + let premium = premiums.as_slice()?; + let entry = entry_prices.as_slice()?; + let qty = quantities.as_slice()?; + let mult = multipliers.as_slice()?; + + let n_legs = inst.len(); + if side.len() != n_legs + || opt_t.len() != n_legs + || strike.len() != n_legs + || premium.len() != n_legs + || entry.len() != n_legs + || qty.len() != n_legs + || mult.len() != n_legs + { + return Err(PyValueError::new_err( + "All leg arrays must have the same length", + )); + } + + let mut total = vec![0.0_f64; grid.len()]; + + for leg_idx in 0..n_legs { + let instrument = parse_instrument(inst[leg_idx])?; + let side_sign = parse_side(side[leg_idx])?.sign(); + let leg_scale = side_sign * qty[leg_idx] * mult[leg_idx]; + + match instrument { + Instrument::Option => { + let otype = parse_option_type(opt_t[leg_idx])?; + let k = strike[leg_idx]; + let p = premium[leg_idx]; + for (i, &s) in grid.iter().enumerate() { + let intrinsic = match otype { + OptionType::Call => (s - k).max(0.0), + OptionType::Put => (k - s).max(0.0), + }; + total[i] += leg_scale * (intrinsic - p); + } + } + Instrument::Future | Instrument::Stock => { + let e = entry[leg_idx]; + for (i, &s) in grid.iter().enumerate() { + total[i] += leg_scale * (s - e); + } + } + } + } + + Ok(total.into_pyarray(py)) +} + +/// Compute aggregate strategy payoff from Python leg objects. +/// +/// `legs` is expected to be a sequence of `PayoffLeg`-like objects +/// with attributes used by `ferro_ta.analysis.derivatives_payoff`. +#[pyfunction] +pub fn strategy_payoff_legs<'py>( + py: Python<'py>, + spot_grid: PyReadonlyArray1<'py, f64>, + legs: Bound<'py, PyTuple>, +) -> PyResult>> { + let grid = spot_grid.as_slice()?; + let mut total = vec![0.0_f64; grid.len()]; + + for leg in legs.iter() { + let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?; + let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign(); + let quantity = leg_attr_f64(&leg, "quantity")?; + let multiplier = leg_attr_f64(&leg, "multiplier")?; + let leg_scale = side_sign * quantity * multiplier; + + match instrument { + Instrument::Option => { + let otype_raw = + leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| { + PyValueError::new_err("Option payoff legs require option_type.") + })?; + let otype = parse_option_type_label(&otype_raw)?; + let strike = leg_attr_optional_f64(&leg, "strike")? + .ok_or_else(|| PyValueError::new_err("Option payoff legs require strike."))?; + let premium = leg_attr_f64(&leg, "premium")?; + + for (i, &s) in grid.iter().enumerate() { + let intrinsic = match otype { + OptionType::Call => (s - strike).max(0.0), + OptionType::Put => (strike - s).max(0.0), + }; + total[i] += leg_scale * (intrinsic - premium); + } + } + Instrument::Future | Instrument::Stock => { + let entry_price = leg_attr_optional_f64(&leg, "entry_price")?.ok_or_else(|| { + PyValueError::new_err("Futures/stock payoff legs require entry_price.") + })?; + for (i, &s) in grid.iter().enumerate() { + total[i] += leg_scale * (s - entry_price); + } + } + } + } + + Ok(total.into_pyarray(py)) +} + +/// Aggregate Greeks over multiple legs. +/// +/// Encodings match `strategy_payoff_dense`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub fn aggregate_greeks_dense( + spot: f64, + instruments: PyReadonlyArray1<'_, i64>, + sides: PyReadonlyArray1<'_, i64>, + option_types: PyReadonlyArray1<'_, i64>, + strikes: PyReadonlyArray1<'_, f64>, + volatilities: PyReadonlyArray1<'_, f64>, + time_to_expiries: PyReadonlyArray1<'_, f64>, + rates: PyReadonlyArray1<'_, f64>, + carries: PyReadonlyArray1<'_, f64>, + quantities: PyReadonlyArray1<'_, f64>, + multipliers: PyReadonlyArray1<'_, f64>, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let inst = instruments.as_slice()?; + let side = sides.as_slice()?; + let opt_t = option_types.as_slice()?; + let strike = strikes.as_slice()?; + let vol = volatilities.as_slice()?; + let tte = time_to_expiries.as_slice()?; + let rate = rates.as_slice()?; + let carry = carries.as_slice()?; + let qty = quantities.as_slice()?; + let mult = multipliers.as_slice()?; + + let n_legs = inst.len(); + if side.len() != n_legs + || opt_t.len() != n_legs + || strike.len() != n_legs + || vol.len() != n_legs + || tte.len() != n_legs + || rate.len() != n_legs + || carry.len() != n_legs + || qty.len() != n_legs + || mult.len() != n_legs + { + return Err(PyValueError::new_err( + "All leg arrays must have the same length", + )); + } + + let mut delta = 0.0_f64; + let mut gamma = 0.0_f64; + let mut vega = 0.0_f64; + let mut theta = 0.0_f64; + let mut rho = 0.0_f64; + + for i in 0..n_legs { + let instrument = parse_instrument(inst[i])?; + let side_sign = parse_side(side[i])?.sign(); + let leg_scale = side_sign * qty[i] * mult[i]; + match instrument { + Instrument::Future | Instrument::Stock => { + delta += leg_scale; + } + Instrument::Option => { + if vol[i].is_nan() || tte[i].is_nan() { + return Err(PyValueError::new_err( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.", + )); + } + let kind = match parse_option_type(opt_t[i])? { + OptionType::Call => ferro_ta_core::options::OptionKind::Call, + OptionType::Put => ferro_ta_core::options::OptionKind::Put, + }; + let greeks = ferro_ta_core::options::greeks::model_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model: ferro_ta_core::options::PricingModel::BlackScholes, + underlying: spot, + strike: strike[i], + rate: rate[i], + carry: carry[i], + time_to_expiry: tte[i], + kind, + }, + volatility: vol[i], + }, + ); + delta += leg_scale * greeks.delta; + gamma += leg_scale * greeks.gamma; + vega += leg_scale * greeks.vega; + theta += leg_scale * greeks.theta; + rho += leg_scale * greeks.rho; + } + } + } + + Ok((delta, gamma, vega, theta, rho)) +} + +/// Aggregate Greeks from Python leg objects. +#[pyfunction] +pub fn aggregate_greeks_legs( + spot: f64, + legs: Bound<'_, PyTuple>, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let mut delta = 0.0_f64; + let mut gamma = 0.0_f64; + let mut vega = 0.0_f64; + let mut theta = 0.0_f64; + let mut rho = 0.0_f64; + + for leg in legs.iter() { + let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?; + let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign(); + let quantity = leg_attr_f64(&leg, "quantity")?; + let multiplier = leg_attr_f64(&leg, "multiplier")?; + let leg_scale = side_sign * quantity * multiplier; + + match instrument { + Instrument::Future | Instrument::Stock => { + delta += leg_scale; + } + Instrument::Option => { + let otype_raw = + leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| { + PyValueError::new_err( + "Option legs require option_type for Greeks aggregation.", + ) + })?; + let otype = parse_option_type_label(&otype_raw)?; + let strike = leg_attr_optional_f64(&leg, "strike")?.ok_or_else(|| { + PyValueError::new_err( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.", + ) + })?; + let volatility = leg_attr_optional_f64(&leg, "volatility")?.ok_or_else(|| { + PyValueError::new_err( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.", + ) + })?; + let time_to_expiry = + leg_attr_optional_f64(&leg, "time_to_expiry")?.ok_or_else(|| { + PyValueError::new_err( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.", + ) + })?; + let rate = leg_attr_f64(&leg, "rate")?; + let carry = leg_attr_f64(&leg, "carry")?; + + let kind = match otype { + OptionType::Call => ferro_ta_core::options::OptionKind::Call, + OptionType::Put => ferro_ta_core::options::OptionKind::Put, + }; + let greeks = ferro_ta_core::options::greeks::model_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model: ferro_ta_core::options::PricingModel::BlackScholes, + underlying: spot, + strike, + rate, + carry, + time_to_expiry, + kind, + }, + volatility, + }, + ); + delta += leg_scale * greeks.delta; + gamma += leg_scale * greeks.gamma; + vega += leg_scale * greeks.vega; + theta += leg_scale * greeks.theta; + rho += leg_scale * greeks.rho; + } + } + } + + Ok((delta, gamma, vega, theta, rho)) +} + +/// Compute BSM-based strategy value over a spot grid (pre-expiry mark-to-market). +/// +/// Unlike `strategy_payoff_dense` (which uses intrinsic at expiry), this function +/// values each option leg using the Black-Scholes model price. Futures and stock +/// legs are valued the same as in `strategy_payoff_dense`. +/// +/// Delegates to `ferro_ta_core::options::payoff::strategy_value_grid`. +/// +/// NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;` +/// for this function to compile. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub fn strategy_value_dense<'py>( + py: Python<'py>, + spot_grid: PyReadonlyArray1<'py, f64>, + instruments: PyReadonlyArray1<'py, i64>, + sides: PyReadonlyArray1<'py, i64>, + option_types: PyReadonlyArray1<'py, i64>, + strikes: PyReadonlyArray1<'py, f64>, + premiums: PyReadonlyArray1<'py, f64>, + entry_prices: PyReadonlyArray1<'py, f64>, + quantities: PyReadonlyArray1<'py, f64>, + multipliers: PyReadonlyArray1<'py, f64>, + time_to_expiries: PyReadonlyArray1<'py, f64>, + volatilities: PyReadonlyArray1<'py, f64>, + rates_per_leg: PyReadonlyArray1<'py, f64>, + carries_per_leg: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let grid = spot_grid.as_slice()?; + let inst = instruments.as_slice()?; + let side = sides.as_slice()?; + let opt_t = option_types.as_slice()?; + let strike = strikes.as_slice()?; + let premium = premiums.as_slice()?; + let entry = entry_prices.as_slice()?; + let qty = quantities.as_slice()?; + let mult = multipliers.as_slice()?; + let tte = time_to_expiries.as_slice()?; + let vol = volatilities.as_slice()?; + let rate = rates_per_leg.as_slice()?; + let carry = carries_per_leg.as_slice()?; + + let result = ferro_ta_core::options::payoff::strategy_value_grid( + grid, inst, side, opt_t, strike, premium, entry, qty, mult, tte, vol, rate, carry, + ); + + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/options/pricing.rs b/ferro-ta-main/src/options/pricing.rs new file mode 100644 index 0000000..dff3d43 --- /dev/null +++ b/ferro-ta-main/src/options/pricing.rs @@ -0,0 +1,151 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (spot, strike, rate, time_to_expiry, volatility, option_type = "call", dividend_yield = 0.0))] +pub fn bsm_price( + spot: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + dividend_yield: f64, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + Ok(ferro_ta_core::options::pricing::black_scholes_price( + spot, + strike, + rate, + dividend_yield, + time_to_expiry, + volatility, + kind, + )) +} + +#[pyfunction] +#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))] +pub fn black76_price( + forward: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + Ok(ferro_ta_core::options::pricing::black_76_price( + forward, + strike, + rate, + time_to_expiry, + volatility, + kind, + )) +} + +#[pyfunction] +#[pyo3(signature = (spot, strike, rate, time_to_expiry, volatility, dividend_yield, option_type = "call"))] +#[allow(clippy::too_many_arguments)] +pub fn bsm_price_batch<'py>( + py: Python<'py>, + spot: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + dividend_yield: PyReadonlyArray1<'py, f64>, + option_type: &str, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let spot = spot.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let volatility = volatility.as_slice()?; + let dividend_yield = dividend_yield.as_slice()?; + validation::validate_equal_length(&[ + (spot.len(), "spot"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (volatility.len(), "volatility"), + (dividend_yield.len(), "dividend_yield"), + ])?; + + let out: Vec = spot + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(volatility.iter()) + .zip(dividend_yield.iter()) + .map(|(((((&s, &k), &r), &t), &vol), &q)| { + ferro_ta_core::options::pricing::black_scholes_price(s, k, r, q, t, vol, kind) + }) + .collect(); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (call_price, put_price, spot, strike, rate, time_to_expiry, carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn put_call_parity_deviation( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + carry: f64, +) -> PyResult { + Ok(ferro_ta_core::options::pricing::put_call_parity_deviation( + call_price, + put_price, + spot, + strike, + rate, + carry, + time_to_expiry, + )) +} + +#[pyfunction] +#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))] +pub fn black76_price_batch<'py>( + py: Python<'py>, + forward: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let forward = forward.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let volatility = volatility.as_slice()?; + validation::validate_equal_length(&[ + (forward.len(), "forward"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (volatility.len(), "volatility"), + ])?; + + let out: Vec = forward + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(volatility.iter()) + .map(|((((&f, &k), &r), &t), &vol)| { + ferro_ta_core::options::pricing::black_76_price(f, k, r, t, vol, kind) + }) + .collect(); + Ok(out.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/options/realized_vol.rs b/ferro-ta-main/src/options/realized_vol.rs new file mode 100644 index 0000000..5925018 --- /dev/null +++ b/ferro-ta-main/src/options/realized_vol.rs @@ -0,0 +1,115 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +use ferro_ta_core::options::realized_vol as core; + +#[pyfunction] +#[pyo3(signature = (close, window, trading_days = 252.0))] +pub fn close_to_close_vol<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok(core::close_to_close_vol(close.as_slice()?, window, trading_days).into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (high, low, window, trading_days = 252.0))] +pub fn parkinson_vol<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok( + core::parkinson_vol(high.as_slice()?, low.as_slice()?, window, trading_days) + .into_pyarray(py), + ) +} + +#[pyfunction] +#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))] +#[allow(clippy::too_many_arguments)] +pub fn garman_klass_vol<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok(core::garman_klass_vol( + open.as_slice()?, + high.as_slice()?, + low.as_slice()?, + close.as_slice()?, + window, + trading_days, + ) + .into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))] +#[allow(clippy::too_many_arguments)] +pub fn rogers_satchell_vol<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok(core::rogers_satchell_vol( + open.as_slice()?, + high.as_slice()?, + low.as_slice()?, + close.as_slice()?, + window, + trading_days, + ) + .into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))] +#[allow(clippy::too_many_arguments)] +pub fn yang_zhang_vol<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok(core::yang_zhang_vol( + open.as_slice()?, + high.as_slice()?, + low.as_slice()?, + close.as_slice()?, + window, + trading_days, + ) + .into_pyarray(py)) +} + +/// Returns a list of (window, min, p25, median, p75, max) tuples. +#[allow(clippy::type_complexity)] +#[pyfunction] +#[pyo3(signature = (close, windows, trading_days = 252.0))] +pub fn vol_cone( + close: PyReadonlyArray1<'_, f64>, + windows: Vec, + trading_days: f64, +) -> PyResult> { + let slices = core::vol_cone(close.as_slice()?, &windows, trading_days); + Ok(slices + .into_iter() + .map(|s| (s.window, s.min, s.p25, s.median, s.p75, s.max)) + .collect()) +} diff --git a/ferro-ta-main/src/options/surface.rs b/ferro-ta-main/src/options/surface.rs new file mode 100644 index 0000000..93b6d10 --- /dev/null +++ b/ferro-ta-main/src/options/surface.rs @@ -0,0 +1,65 @@ +use crate::validation; +use numpy::PyReadonlyArray1; +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (strikes, vols, reference_price, time_to_expiry, model = "bsm", rate = 0.0, carry = 0.0))] +pub fn smile_metrics<'py>( + strikes: PyReadonlyArray1<'py, f64>, + vols: PyReadonlyArray1<'py, f64>, + reference_price: f64, + time_to_expiry: f64, + model: &str, + rate: f64, + carry: f64, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let strikes = strikes.as_slice()?; + let vols = vols.as_slice()?; + validation::validate_equal_length(&[(strikes.len(), "strikes"), (vols.len(), "vols")])?; + let model = super::parse_pricing_model(model)?; + let metrics = ferro_ta_core::options::surface::smile_metrics( + strikes, + vols, + reference_price, + rate, + carry, + time_to_expiry, + model, + ); + Ok(( + metrics.atm_iv, + metrics.risk_reversal_25d, + metrics.butterfly_25d, + metrics.skew_slope, + metrics.convexity, + )) +} + +#[pyfunction] +pub fn term_structure_slope<'py>( + tenors: PyReadonlyArray1<'py, f64>, + atm_ivs: PyReadonlyArray1<'py, f64>, +) -> PyResult { + let tenors = tenors.as_slice()?; + let atm_ivs = atm_ivs.as_slice()?; + validation::validate_equal_length(&[(tenors.len(), "tenors"), (atm_ivs.len(), "atm_ivs")])?; + Ok(ferro_ta_core::options::surface::term_structure_slope( + tenors, atm_ivs, + )) +} + +#[pyfunction] +#[pyo3(signature = (spot, iv, days_to_expiry, trading_days_per_year = 252.0))] +pub fn expected_move( + spot: f64, + iv: f64, + days_to_expiry: f64, + trading_days_per_year: f64, +) -> PyResult<(f64, f64)> { + Ok(ferro_ta_core::options::surface::expected_move( + spot, + iv, + days_to_expiry, + trading_days_per_year, + )) +} diff --git a/ferro-ta-main/src/overlap/bbands.rs b/ferro-ta-main/src/overlap/bbands.rs new file mode 100644 index 0000000..2f6dee4 --- /dev/null +++ b/ferro-ta-main/src/overlap/bbands.rs @@ -0,0 +1,30 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Bollinger Bands. Returns (upper, middle, lower). Middle is SMA; bands are ± nbdev * stddev. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 5, nbdevup = 2.0, nbdevdn = 2.0))] +#[allow(clippy::type_complexity)] +pub fn bbands<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + nbdevup: f64, + nbdevdn: f64, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + log::debug!("BBANDS: timeperiod={timeperiod}, n={}", prices.len()); + let (upper, middle, lower) = + ferro_ta_core::overlap::bbands(prices, timeperiod, nbdevup, nbdevdn); + Ok(( + upper.into_pyarray(py), + middle.into_pyarray(py), + lower.into_pyarray(py), + )) +} diff --git a/ferro-ta-main/src/overlap/dema.rs b/ferro-ta-main/src/overlap/dema.rs new file mode 100644 index 0000000..6ff838c --- /dev/null +++ b/ferro-ta-main/src/overlap/dema.rs @@ -0,0 +1,40 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::ExponentialMovingAverage; +use ta::Next; + +/// Double Exponential Moving Average. Converges after ~2*(timeperiod-1) bars. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn dema<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut ema1 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema2 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let warmup = 2 * (timeperiod - 1); + let mut ema1_vals = vec![f64::NAN; n]; + let mut result = vec![f64::NAN; n]; + + for (i, &price) in prices.iter().enumerate() { + let v1 = ema1.next(price); + if i + 1 >= timeperiod { + ema1_vals[i] = v1; + let v2 = ema2.next(v1); + if i >= warmup { + result[i] = 2.0 * v1 - v2; + } + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/ema.rs b/ferro-ta-main/src/overlap/ema.rs new file mode 100644 index 0000000..2139024 --- /dev/null +++ b/ferro-ta-main/src/overlap/ema.rs @@ -0,0 +1,19 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Exponential Moving Average. Leading timeperiod-1 values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn ema<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + log::debug!("EMA: timeperiod={timeperiod}, n={n}"); + let result = ferro_ta_core::overlap::ema(prices, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/kama.rs b/ferro-ta-main/src/overlap/kama.rs new file mode 100644 index 0000000..7eef036 --- /dev/null +++ b/ferro-ta-main/src/overlap/kama.rs @@ -0,0 +1,43 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Kaufman Adaptive Moving Average. First value at index timeperiod-1. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn kama<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + if n < timeperiod { + return Ok(vec![f64::NAN; n].into_pyarray(py)); + } + + let fast_sc = 2.0 / (2.0 + 1.0_f64); + let slow_sc = 2.0 / (30.0 + 1.0_f64); + + let mut result = vec![f64::NAN; n]; + let mut kama_val = prices[timeperiod - 1]; + result[timeperiod - 1] = kama_val; + + for i in timeperiod..n { + let direction = (prices[i] - prices[i - timeperiod]).abs(); + let mut volatility = 0.0_f64; + for j in 1..=timeperiod { + volatility += (prices[i - j + 1] - prices[i - j]).abs(); + } + let er = if volatility > 0.0 { + direction / volatility + } else { + 0.0 + }; + let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2); + kama_val += sc * (prices[i] - kama_val); + result[i] = kama_val; + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/ma_mavp.rs b/ferro-ta-main/src/overlap/ma_mavp.rs new file mode 100644 index 0000000..a0e8082 --- /dev/null +++ b/ferro-ta-main/src/overlap/ma_mavp.rs @@ -0,0 +1,58 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use super::{dema, ema, kama, sma, t3, tema, trima, wma}; + +/// Generic Moving Average. matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30, matype = 0))] +pub fn ma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + matype: u8, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + match matype { + 0 => sma::sma_inner(py, close, timeperiod), + 1 => ema::ema(py, close, timeperiod), + 2 => wma::wma(py, close, timeperiod), + 3 => dema::dema(py, close, timeperiod), + 4 => tema::tema(py, close, timeperiod), + 5 => trima::trima(py, close, timeperiod), + 6 => kama::kama(py, close, timeperiod), + 7 => t3::t3(py, close, timeperiod, 0.7), + _ => Err(PyValueError::new_err( + "matype must be 0–7 (SMA/EMA/WMA/DEMA/TEMA/TRIMA/KAMA/T3)", + )), + } +} + +/// Moving Average with variable period per bar (SMA over period from periods array). +#[pyfunction] +#[pyo3(signature = (close, periods, minperiod = 2, maxperiod = 30))] +pub fn mavp<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + periods: PyReadonlyArray1<'py, f64>, + minperiod: usize, + maxperiod: usize, +) -> PyResult>> { + let prices = close.as_slice()?; + let per = periods.as_slice()?; + let n = prices.len(); + validation::validate_equal_length(&[(n, "close"), (per.len(), "periods")])?; + validation::validate_timeperiod(minperiod, "minperiod", 1)?; + validation::validate_timeperiod(maxperiod, "maxperiod", minperiod)?; + let mut result = vec![f64::NAN; n]; + for i in 0..n { + let p = (per[i].round() as usize).clamp(minperiod, maxperiod); + if i + 1 >= p { + let sum: f64 = prices[(i + 1 - p)..=i].iter().sum(); + result[i] = sum / p as f64; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/macd.rs b/ferro-ta-main/src/overlap/macd.rs new file mode 100644 index 0000000..2b8d1fd --- /dev/null +++ b/ferro-ta-main/src/overlap/macd.rs @@ -0,0 +1,57 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// MACD (EMA-based). Returns (macd_line, signal_line, histogram). +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))] +#[allow(clippy::type_complexity)] +pub fn macd<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + 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( + "fastperiod must be less than slowperiod", + )); + } + let prices = close.as_slice()?; + log::debug!( + "MACD: fast={fastperiod}, slow={slowperiod}, signal={signalperiod}, n={}", + prices.len() + ); + let (macd_line, signal_line, histogram) = + ferro_ta_core::overlap::macd(prices, fastperiod, slowperiod, signalperiod); + Ok(( + macd_line.into_pyarray(py), + signal_line.into_pyarray(py), + histogram.into_pyarray(py), + )) +} + +/// MACD with fixed 12/26 periods. Returns (macd_line, signal_line, histogram). +#[pyfunction] +#[pyo3(signature = (close, signalperiod = 9))] +#[allow(clippy::type_complexity)] +pub fn macdfix<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + signalperiod: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + macd(py, close, 12, 26, signalperiod) +} diff --git a/ferro-ta-main/src/overlap/macdext.rs b/ferro-ta-main/src/overlap/macdext.rs new file mode 100644 index 0000000..e779abc --- /dev/null +++ b/ferro-ta-main/src/overlap/macdext.rs @@ -0,0 +1,116 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +fn compute_ma_slice(prices: &[f64], period: usize, matype: u8) -> Vec { + let n = prices.len(); + match matype { + 1 => { + if period == 0 { + return vec![f64::NAN; n]; + } + let k = 2.0 / (period as f64 + 1.0); + let mut result = vec![f64::NAN; n]; + let mut ema_val = prices[period - 1]; + result[period - 1] = ema_val; + for i in period..n { + ema_val = prices[i] * k + ema_val * (1.0 - k); + result[i] = ema_val; + } + result + } + 2 => { + if period == 0 { + return vec![f64::NAN; n]; + } + let weight_sum = (period * (period + 1) / 2) as f64; + let mut result = vec![f64::NAN; n]; + for i in (period - 1)..n { + let val: f64 = (0..period) + .map(|j| prices[i - j] * (period - j) as f64) + .sum(); + result[i] = val / weight_sum; + } + result + } + _ => { + if period == 0 { + return vec![f64::NAN; n]; + } + let mut result = vec![f64::NAN; n]; + for i in (period - 1)..n { + let sum: f64 = prices[(i + 1 - period)..=i].iter().sum(); + result[i] = sum / period as f64; + } + result + } + } +} + +/// MACD with configurable MA types for fast/slow/signal (matype 0–7). Returns (macd_line, signal_line, histogram). +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, fastmatype = 1, slowperiod = 26, slowmatype = 1, signalperiod = 9, signalmatype = 1))] +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +pub fn macdext<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + fastmatype: u8, + slowperiod: usize, + slowmatype: u8, + signalperiod: usize, + signalmatype: u8, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + 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( + "fastperiod must be less than slowperiod", + )); + } + let prices = close.as_slice()?; + let n = prices.len(); + + let fast_ma = compute_ma_slice(prices, fastperiod, fastmatype); + let slow_ma = compute_ma_slice(prices, slowperiod, slowmatype); + + let mut macd_line = vec![f64::NAN; n]; + let macd_start = slowperiod - 1; + for i in macd_start..n { + if !fast_ma[i].is_nan() && !slow_ma[i].is_nan() { + macd_line[i] = fast_ma[i] - slow_ma[i]; + } + } + + let macd_valid: Vec = macd_line[macd_start..].to_vec(); + let signal_slice = compute_ma_slice(&macd_valid, signalperiod, signalmatype); + + let mut signal_line = vec![f64::NAN; n]; + let warmup = macd_start + signalperiod - 1; + #[allow(clippy::needless_range_loop)] + for i in warmup..n { + let j = i - macd_start; + if j < signal_slice.len() && !signal_slice[j].is_nan() { + signal_line[i] = signal_slice[j]; + } + } + + let mut histogram = vec![f64::NAN; n]; + for i in 0..n { + if !macd_line[i].is_nan() && !signal_line[i].is_nan() { + histogram[i] = macd_line[i] - signal_line[i]; + } + } + + Ok(( + macd_line.into_pyarray(py), + signal_line.into_pyarray(py), + histogram.into_pyarray(py), + )) +} diff --git a/ferro-ta-main/src/overlap/mama.rs b/ferro-ta-main/src/overlap/mama.rs new file mode 100644 index 0000000..937d9fe --- /dev/null +++ b/ferro-ta-main/src/overlap/mama.rs @@ -0,0 +1,138 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// MESA Adaptive Moving Average. Returns (mama, fama). Uses Hilbert Transform–based period. +#[pyfunction] +#[pyo3(signature = (close, fastlimit = 0.5, slowlimit = 0.05))] +#[allow(clippy::type_complexity)] +pub fn mama<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastlimit: f64, + slowlimit: f64, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let prices = close.as_slice()?; + let n = prices.len(); + + let lookback = 32; + let mut mama_arr = vec![f64::NAN; n]; + let mut fama_arr = vec![f64::NAN; n]; + + if n <= lookback { + return Ok((mama_arr.into_pyarray(py), fama_arr.into_pyarray(py))); + } + + 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] + }; + } + + let mut detrender = vec![0.0f64; n]; + let mut q1 = vec![0.0f64; n]; + let mut i1 = vec![0.0f64; n]; + let mut ji = vec![0.0f64; n]; + let mut jq = vec![0.0f64; n]; + let mut i2 = vec![0.0f64; n]; + let mut q2 = vec![0.0f64; n]; + let mut re = vec![0.0f64; n]; + let mut im = vec![0.0f64; n]; + let mut period = vec![0.0f64; n]; + let mut phase = vec![0.0f64; n]; + + let mut mama_val = prices[0]; + let mut fama_val = prices[0]; + + for i in 6..n { + let prev_period = period[i - 1].max(1.0); + let alpha = 0.075 * prev_period + 0.54; + + detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2] + - 0.5769 * smooth[i - 4] + - 0.0962 * smooth[i - 6]) + * alpha; + + if i >= 12 { + q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2] + - 0.5769 * detrender[i - 4] + - 0.0962 * detrender[i - 6]) + * alpha; + } + + if i >= 9 { + i1[i] = detrender[i - 3]; + } + + if i >= 15 { + ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6]) + * alpha; + } + + if i >= 18 { + jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6]) + * alpha; + } + + let i2_raw = i1[i] - jq[i]; + let q2_raw = q1[i] + ji[i]; + + 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; + + let re_raw = i2[i] * i2_prev + q2[i] * q2_prev; + let im_raw = i2[i] * q2_prev - q2[i] * i2_prev; + re[i] = 0.2 * re_raw + 0.8 * re[i - 1]; + im[i] = 0.2 * im_raw + 0.8 * im[i - 1]; + + let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 { + std::f64::consts::PI * 2.0 / (im[i] / re[i]).atan() + } else { + prev_period + }; + + if p > 1.5 * prev_period { + p = 1.5 * prev_period; + } + if p < 0.67 * prev_period { + p = 0.67 * prev_period; + } + p = p.clamp(6.0, 50.0); + + period[i] = 0.2 * p + 0.8 * prev_period; + + let prev_phase = phase[i - 1]; + phase[i] = if i1[i] != 0.0 { + q1[i].atan2(i1[i]) * 180.0 / std::f64::consts::PI + } else if q1[i] > 0.0 { + 90.0 + } else if q1[i] < 0.0 { + -90.0 + } else { + 0.0 + }; + + let mut delta_phase = prev_phase - phase[i]; + if delta_phase < 1.0 { + delta_phase = 1.0; + } + let adaptive_alpha = fastlimit / delta_phase; + let adaptive_alpha = adaptive_alpha.clamp(slowlimit, fastlimit); + + if i >= lookback { + mama_val = adaptive_alpha * prices[i] + (1.0 - adaptive_alpha) * mama_val; + fama_val = 0.5 * adaptive_alpha * mama_val + (1.0 - 0.5 * adaptive_alpha) * fama_val; + mama_arr[i] = mama_val; + fama_arr[i] = fama_val; + } else { + mama_val = prices[i]; + fama_val = prices[i]; + } + } + + Ok((mama_arr.into_pyarray(py), fama_arr.into_pyarray(py))) +} diff --git a/ferro-ta-main/src/overlap/midpoint.rs b/ferro-ta-main/src/overlap/midpoint.rs new file mode 100644 index 0000000..c157a1d --- /dev/null +++ b/ferro-ta-main/src/overlap/midpoint.rs @@ -0,0 +1,30 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::{Maximum, Minimum}; +use ta::Next; + +/// Midpoint: (max(close) + min(close)) / 2 over the rolling window. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn midpoint<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut max_ind = Maximum::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut min_ind = Minimum::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let mx = max_ind.next(price); + let mn = min_ind.next(price); + if i + 1 >= timeperiod { + result[i] = (mx + mn) / 2.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/midprice.rs b/ferro-ta-main/src/overlap/midprice.rs new file mode 100644 index 0000000..6fb96b9 --- /dev/null +++ b/ferro-ta-main/src/overlap/midprice.rs @@ -0,0 +1,34 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; +use ta::indicators::{Maximum, Minimum}; +use ta::Next; + +/// MidPrice: (highest high + lowest low) / 2 over the rolling window. +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +pub fn midprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + let mut max_ind = Maximum::new(timeperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let mut min_ind = Minimum::new(timeperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, (&h, &l)) in highs.iter().zip(lows.iter()).enumerate() { + let mx = max_ind.next(h); + let mn = min_ind.next(l); + if i + 1 >= timeperiod { + result[i] = (mx + mn) / 2.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/mod.rs b/ferro-ta-main/src/overlap/mod.rs new file mode 100644 index 0000000..deab633 --- /dev/null +++ b/ferro-ta-main/src/overlap/mod.rs @@ -0,0 +1,47 @@ +//! Overlap studies — moving averages and trend indicators. +//! Each indicator lives in its own file for maintainability. + +mod bbands; +mod dema; +mod ema; +mod kama; +mod ma_mavp; +mod macd; +mod macdext; +mod mama; +mod midpoint; +mod midprice; +mod sar; +mod sarext; +mod sma; +mod t3; +mod tema; +mod trima; +mod wma; + +pub use ma_mavp::{ma, mavp}; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::sma::sma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ema::ema, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::wma::wma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::dema::dema, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::tema::tema, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::trima::trima, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::kama::kama, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::t3::t3, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::bbands::bbands, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::macd::macd, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::macd::macdfix, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::sar::sar, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::midpoint::midpoint, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::midprice::midprice, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(ma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(mavp, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::mama::mama, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::sarext::sarext, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::macdext::macdext, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/overlap/sar.rs b/ferro-ta-main/src/overlap/sar.rs new file mode 100644 index 0000000..8fd5257 --- /dev/null +++ b/ferro-ta-main/src/overlap/sar.rs @@ -0,0 +1,70 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Parabolic SAR. Same shape as TA-Lib; reversal history may differ slightly. +#[pyfunction] +#[pyo3(signature = (high, low, acceleration = 0.02, maximum = 0.2))] +pub fn sar<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + acceleration: f64, + maximum: f64, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + if n < 2 { + return Ok(vec![f64::NAN; n].into_pyarray(py)); + } + + let mut result = vec![f64::NAN; n]; + + let mut is_rising = highs[1] >= highs[0]; + let mut af = acceleration; + let mut ep: f64; + let mut sar_val: f64; + + if is_rising { + sar_val = lows[0]; + ep = highs[1]; + } else { + sar_val = highs[0]; + ep = lows[1]; + } + result[1] = sar_val; + + for i in 2..n { + let prev_sar = sar_val; + sar_val = prev_sar + af * (ep - prev_sar); + + if is_rising { + sar_val = sar_val.min(lows[i - 1]).min(lows[i - 2]); + if lows[i] < sar_val { + is_rising = false; + sar_val = ep; + ep = lows[i]; + af = acceleration; + } else if highs[i] > ep { + ep = highs[i]; + af = (af + acceleration).min(maximum); + } + } else { + sar_val = sar_val.max(highs[i - 1]).max(highs[i - 2]); + if highs[i] > sar_val { + is_rising = true; + sar_val = ep; + ep = highs[i]; + af = acceleration; + } else if lows[i] < ep { + ep = lows[i]; + af = (af + acceleration).min(maximum); + } + } + result[i] = sar_val; + } + + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/sarext.rs b/ferro-ta-main/src/overlap/sarext.rs new file mode 100644 index 0000000..c11d8f2 --- /dev/null +++ b/ferro-ta-main/src/overlap/sarext.rs @@ -0,0 +1,103 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Parabolic SAR Extended: SAR with configurable start value and long/short acceleration. +#[pyfunction] +#[pyo3(signature = (high, low, startvalue = 0.0, offsetonreverse = 0.0, accelerationinitlong = 0.02, accelerationlong = 0.02, accelerationmaxlong = 0.2, accelerationinitshort = 0.02, accelerationshort = 0.02, accelerationmaxshort = 0.2))] +#[allow(clippy::too_many_arguments)] +pub fn sarext<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + startvalue: f64, + offsetonreverse: f64, + accelerationinitlong: f64, + accelerationlong: f64, + accelerationmaxlong: f64, + accelerationinitshort: f64, + accelerationshort: f64, + accelerationmaxshort: f64, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + if n < 2 { + return Ok(vec![f64::NAN; n].into_pyarray(py)); + } + + let mut result = vec![f64::NAN; n]; + + let mut is_rising = highs[1] >= highs[0]; + + let (mut af, af_step, af_max) = if is_rising { + (accelerationinitlong, accelerationlong, accelerationmaxlong) + } else { + ( + accelerationinitshort, + accelerationshort, + accelerationmaxshort, + ) + }; + + let mut ep: f64; + let mut sar_val: f64; + + if is_rising { + sar_val = if startvalue != 0.0 { + startvalue + } else { + lows[0] + }; + ep = highs[1]; + } else { + sar_val = if startvalue != 0.0 { + -startvalue + } else { + highs[0] + }; + ep = lows[1]; + } + + result[1] = sar_val; + + let mut af_step_cur = af_step; + let mut af_max_cur = af_max; + + for i in 2..n { + let prev_sar = sar_val; + sar_val = prev_sar + af * (ep - prev_sar); + + if is_rising { + sar_val = sar_val.min(lows[i - 1]).min(lows[i - 2]); + if lows[i] < sar_val { + is_rising = false; + sar_val = ep + sar_val.abs() * offsetonreverse; + ep = lows[i]; + af = accelerationinitshort; + af_step_cur = accelerationshort; + af_max_cur = accelerationmaxshort; + } else if highs[i] > ep { + ep = highs[i]; + af = (af + af_step_cur).min(af_max_cur); + } + } else { + sar_val = sar_val.max(highs[i - 1]).max(highs[i - 2]); + if highs[i] > sar_val { + is_rising = true; + sar_val = ep - sar_val.abs() * offsetonreverse; + ep = highs[i]; + af = accelerationinitlong; + af_step_cur = accelerationlong; + af_max_cur = accelerationmaxlong; + } else if lows[i] < ep { + ep = lows[i]; + af = (af + af_step_cur).min(af_max_cur); + } + } + result[i] = sar_val; + } + + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/sma.rs b/ferro-ta-main/src/overlap/sma.rs new file mode 100644 index 0000000..f28c384 --- /dev/null +++ b/ferro-ta-main/src/overlap/sma.rs @@ -0,0 +1,29 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Inner SMA implementation (timeperiod already validated as usize). +/// Used by the PyO3 sma() and by ma() when matype=0. +pub fn sma_inner<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + let prices = close.as_slice()?; + let n = prices.len(); + log::debug!("SMA: timeperiod={timeperiod}, n={n}"); + let result = ferro_ta_core::overlap::sma(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Simple Moving Average. Leading timeperiod-1 values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn sma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: i64, +) -> PyResult>> { + let timeperiod = validation::parse_timeperiod(timeperiod, "timeperiod", 1)?; + sma_inner(py, close, timeperiod) +} diff --git a/ferro-ta-main/src/overlap/t3.rs b/ferro-ta-main/src/overlap/t3.rs new file mode 100644 index 0000000..d18c99a --- /dev/null +++ b/ferro-ta-main/src/overlap/t3.rs @@ -0,0 +1,46 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Tillson T3 (triple smoothed EMA). Converges after ~6*(timeperiod-1) bars. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 5, vfactor = 0.7))] +pub fn t3<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + vfactor: f64, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut e = [0.0_f64; 6]; + let k = 2.0 / (timeperiod as f64 + 1.0); + + let v = vfactor; + let c1 = -(v * v * v); + let c2 = 3.0 * v * v + 3.0 * v * v * v; + let c3 = -6.0 * v * v - 3.0 * v - 3.0 * v * v * v; + let c4 = 1.0 + 3.0 * v + v * v * v + 3.0 * v * v; + + let warmup = 6 * (timeperiod - 1); + let mut result = vec![f64::NAN; n]; + + for (i, &price) in prices.iter().enumerate() { + if i == 0 { + for ej in e.iter_mut() { + *ej = price; + } + } else { + e[0] += k * (price - e[0]); + for j in 1..6 { + e[j] += k * (e[j - 1] - e[j]); + } + } + if i >= warmup { + result[i] = c1 * e[5] + c2 * e[4] + c3 * e[3] + c4 * e[2]; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/tema.rs b/ferro-ta-main/src/overlap/tema.rs new file mode 100644 index 0000000..bde4309 --- /dev/null +++ b/ferro-ta-main/src/overlap/tema.rs @@ -0,0 +1,45 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::ExponentialMovingAverage; +use ta::Next; + +/// Triple Exponential Moving Average. Converges after ~3*(timeperiod-1) bars. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn tema<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut ema1 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema2 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema3 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let warmup1 = timeperiod - 1; + let warmup2 = 2 * (timeperiod - 1); + let warmup3 = 3 * (timeperiod - 1); + let mut result = vec![f64::NAN; n]; + + for (i, &price) in prices.iter().enumerate() { + let v1 = ema1.next(price); + if i >= warmup1 { + let v2 = ema2.next(v1); + if i >= warmup2 { + let v3 = ema3.next(v2); + if i >= warmup3 { + result[i] = 3.0 * v1 - 3.0 * v2 + v3; + } + } + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/trima.rs b/ferro-ta-main/src/overlap/trima.rs new file mode 100644 index 0000000..8c7469e --- /dev/null +++ b/ferro-ta-main/src/overlap/trima.rs @@ -0,0 +1,34 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Triangular Moving Average (triangle-weighted). Leading timeperiod-1 values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn trima<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut weights = Vec::with_capacity(timeperiod); + let half = timeperiod.div_ceil(2); + for i in 1..=timeperiod { + let w = if i <= half { i } else { timeperiod + 1 - i }; + weights.push(w as f64); + } + let weight_sum: f64 = weights.iter().sum(); + + let mut result = vec![f64::NAN; n]; + for i in (timeperiod - 1)..n { + let mut val = 0.0_f64; + for (j, &w) in weights.iter().enumerate() { + val += prices[i - (timeperiod - 1 - j)] * w; + } + result[i] = val / weight_sum; + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/overlap/wma.rs b/ferro-ta-main/src/overlap/wma.rs new file mode 100644 index 0000000..7b7543c --- /dev/null +++ b/ferro-ta-main/src/overlap/wma.rs @@ -0,0 +1,19 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Weighted Moving Average (linear weights). Leading timeperiod-1 values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn wma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + log::debug!("WMA: timeperiod={timeperiod}, n={n}"); + let result = ferro_ta_core::overlap::wma(prices, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/pattern/cdl2crows.rs b/ferro-ta-main/src/pattern/cdl2crows.rs new file mode 100644 index 0000000..1df0ddb --- /dev/null +++ b/ferro-ta-main/src/pattern/cdl2crows.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl2crows<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdl3blackcrows.rs b/ferro-ta-main/src/pattern/cdl3blackcrows.rs new file mode 100644 index 0000000..ea305e7 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdl3blackcrows.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3blackcrows<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdl3inside.rs b/ferro-ta-main/src/pattern/cdl3inside.rs new file mode 100644 index 0000000..f7c33a6 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdl3inside.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3inside<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdl3linestrike.rs b/ferro-ta-main/src/pattern/cdl3linestrike.rs new file mode 100644 index 0000000..7c599bf --- /dev/null +++ b/ferro-ta-main/src/pattern/cdl3linestrike.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3linestrike<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdl3outside.rs b/ferro-ta-main/src/pattern/cdl3outside.rs new file mode 100644 index 0000000..39a8461 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdl3outside.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3outside<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdl3starsinsouth.rs b/ferro-ta-main/src/pattern/cdl3starsinsouth.rs new file mode 100644 index 0000000..9ff6016 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdl3starsinsouth.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3starsinsouth<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdl3whitesoldiers.rs b/ferro-ta-main/src/pattern/cdl3whitesoldiers.rs new file mode 100644 index 0000000..69771c7 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdl3whitesoldiers.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3whitesoldiers<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlabandonedbaby.rs b/ferro-ta-main/src/pattern/cdlabandonedbaby.rs new file mode 100644 index 0000000..c6f2dac --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlabandonedbaby.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlabandonedbaby<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdladvanceblock.rs b/ferro-ta-main/src/pattern/cdladvanceblock.rs new file mode 100644 index 0000000..692b12b --- /dev/null +++ b/ferro-ta-main/src/pattern/cdladvanceblock.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdladvanceblock<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlbelthold.rs b/ferro-ta-main/src/pattern/cdlbelthold.rs new file mode 100644 index 0000000..3b819b1 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlbelthold.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlbelthold<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlbreakaway.rs b/ferro-ta-main/src/pattern/cdlbreakaway.rs new file mode 100644 index 0000000..ec298f9 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlbreakaway.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlbreakaway<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlclosingmarubozu.rs b/ferro-ta-main/src/pattern/cdlclosingmarubozu.rs new file mode 100644 index 0000000..b63138d --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlclosingmarubozu.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlclosingmarubozu<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlconcealbabyswall.rs b/ferro-ta-main/src/pattern/cdlconcealbabyswall.rs new file mode 100644 index 0000000..5e719ac --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlconcealbabyswall.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlconcealbabyswall<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlcounterattack.rs b/ferro-ta-main/src/pattern/cdlcounterattack.rs new file mode 100644 index 0000000..13c5be2 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlcounterattack.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlcounterattack<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdldarkcloudcover.rs b/ferro-ta-main/src/pattern/cdldarkcloudcover.rs new file mode 100644 index 0000000..049c279 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdldarkcloudcover.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdldarkcloudcover<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdldoji.rs b/ferro-ta-main/src/pattern/cdldoji.rs new file mode 100644 index 0000000..0a2a875 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdldoji.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdldoji<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdldojistar.rs b/ferro-ta-main/src/pattern/cdldojistar.rs new file mode 100644 index 0000000..6bcf3cb --- /dev/null +++ b/ferro-ta-main/src/pattern/cdldojistar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdldojistar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdldragonflydoji.rs b/ferro-ta-main/src/pattern/cdldragonflydoji.rs new file mode 100644 index 0000000..74b6255 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdldragonflydoji.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdldragonflydoji<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlengulfing.rs b/ferro-ta-main/src/pattern/cdlengulfing.rs new file mode 100644 index 0000000..fcbf6d3 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlengulfing.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlengulfing<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdleveningdojistar.rs b/ferro-ta-main/src/pattern/cdleveningdojistar.rs new file mode 100644 index 0000000..39115d2 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdleveningdojistar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdleveningdojistar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdleveningstar.rs b/ferro-ta-main/src/pattern/cdleveningstar.rs new file mode 100644 index 0000000..2f6dc9a --- /dev/null +++ b/ferro-ta-main/src/pattern/cdleveningstar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdleveningstar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlgapsidesidewhite.rs b/ferro-ta-main/src/pattern/cdlgapsidesidewhite.rs new file mode 100644 index 0000000..b481e49 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlgapsidesidewhite.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlgapsidesidewhite<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlgravestonedoji.rs b/ferro-ta-main/src/pattern/cdlgravestonedoji.rs new file mode 100644 index 0000000..0b7f919 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlgravestonedoji.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlgravestonedoji<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlhammer.rs b/ferro-ta-main/src/pattern/cdlhammer.rs new file mode 100644 index 0000000..6bcc282 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlhammer.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhammer<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlhangingman.rs b/ferro-ta-main/src/pattern/cdlhangingman.rs new file mode 100644 index 0000000..c52a4d2 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlhangingman.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhangingman<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlharami.rs b/ferro-ta-main/src/pattern/cdlharami.rs new file mode 100644 index 0000000..cc16e4b --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlharami.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlharami<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlharamicross.rs b/ferro-ta-main/src/pattern/cdlharamicross.rs new file mode 100644 index 0000000..bb947ee --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlharamicross.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlharamicross<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlhighwave.rs b/ferro-ta-main/src/pattern/cdlhighwave.rs new file mode 100644 index 0000000..889df79 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlhighwave.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhighwave<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlhikkake.rs b/ferro-ta-main/src/pattern/cdlhikkake.rs new file mode 100644 index 0000000..34e1712 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlhikkake.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhikkake<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlhikkakemod.rs b/ferro-ta-main/src/pattern/cdlhikkakemod.rs new file mode 100644 index 0000000..2866bb5 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlhikkakemod.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhikkakemod<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlhomingpigeon.rs b/ferro-ta-main/src/pattern/cdlhomingpigeon.rs new file mode 100644 index 0000000..e3d08c8 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlhomingpigeon.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhomingpigeon<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlidentical3crows.rs b/ferro-ta-main/src/pattern/cdlidentical3crows.rs new file mode 100644 index 0000000..a5eeaa6 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlidentical3crows.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlidentical3crows<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlinneck.rs b/ferro-ta-main/src/pattern/cdlinneck.rs new file mode 100644 index 0000000..a43a910 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlinneck.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlinneck<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlinvertedhammer.rs b/ferro-ta-main/src/pattern/cdlinvertedhammer.rs new file mode 100644 index 0000000..4568de8 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlinvertedhammer.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlinvertedhammer<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlkicking.rs b/ferro-ta-main/src/pattern/cdlkicking.rs new file mode 100644 index 0000000..133ca2e --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlkicking.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlkicking<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlkickingbylength.rs b/ferro-ta-main/src/pattern/cdlkickingbylength.rs new file mode 100644 index 0000000..9be040c --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlkickingbylength.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlkickingbylength<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlladderbottom.rs b/ferro-ta-main/src/pattern/cdlladderbottom.rs new file mode 100644 index 0000000..1cc9d88 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlladderbottom.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlladderbottom<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdllongleggeddoji.rs b/ferro-ta-main/src/pattern/cdllongleggeddoji.rs new file mode 100644 index 0000000..e9dc5c6 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdllongleggeddoji.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdllongleggeddoji<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdllongline.rs b/ferro-ta-main/src/pattern/cdllongline.rs new file mode 100644 index 0000000..63bf7a7 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdllongline.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdllongline<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlmarubozu.rs b/ferro-ta-main/src/pattern/cdlmarubozu.rs new file mode 100644 index 0000000..0c146d6 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlmarubozu.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmarubozu<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlmatchinglow.rs b/ferro-ta-main/src/pattern/cdlmatchinglow.rs new file mode 100644 index 0000000..7bc3cfe --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlmatchinglow.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmatchinglow<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlmathold.rs b/ferro-ta-main/src/pattern/cdlmathold.rs new file mode 100644 index 0000000..7e76e74 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlmathold.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmathold<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlmorningdojistar.rs b/ferro-ta-main/src/pattern/cdlmorningdojistar.rs new file mode 100644 index 0000000..be45eb2 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlmorningdojistar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmorningdojistar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlmorningstar.rs b/ferro-ta-main/src/pattern/cdlmorningstar.rs new file mode 100644 index 0000000..ef2f68e --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlmorningstar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmorningstar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlonneck.rs b/ferro-ta-main/src/pattern/cdlonneck.rs new file mode 100644 index 0000000..3b3a933 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlonneck.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlonneck<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlpiercing.rs b/ferro-ta-main/src/pattern/cdlpiercing.rs new file mode 100644 index 0000000..7b862cb --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlpiercing.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlpiercing<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlrickshawman.rs b/ferro-ta-main/src/pattern/cdlrickshawman.rs new file mode 100644 index 0000000..a10d3d7 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlrickshawman.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlrickshawman<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlrisefall3methods.rs b/ferro-ta-main/src/pattern/cdlrisefall3methods.rs new file mode 100644 index 0000000..a35d5ed --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlrisefall3methods.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlrisefall3methods<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlseparatinglines.rs b/ferro-ta-main/src/pattern/cdlseparatinglines.rs new file mode 100644 index 0000000..f59ec8b --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlseparatinglines.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlseparatinglines<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlshootingstar.rs b/ferro-ta-main/src/pattern/cdlshootingstar.rs new file mode 100644 index 0000000..4d0d214 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlshootingstar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlshootingstar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlshortline.rs b/ferro-ta-main/src/pattern/cdlshortline.rs new file mode 100644 index 0000000..b27dc52 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlshortline.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlshortline<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlspinningtop.rs b/ferro-ta-main/src/pattern/cdlspinningtop.rs new file mode 100644 index 0000000..909c9c5 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlspinningtop.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlspinningtop<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlstalledpattern.rs b/ferro-ta-main/src/pattern/cdlstalledpattern.rs new file mode 100644 index 0000000..9bca03f --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlstalledpattern.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlstalledpattern<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlsticksandwich.rs b/ferro-ta-main/src/pattern/cdlsticksandwich.rs new file mode 100644 index 0000000..ba7ebff --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlsticksandwich.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlsticksandwich<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdltakuri.rs b/ferro-ta-main/src/pattern/cdltakuri.rs new file mode 100644 index 0000000..7f9270f --- /dev/null +++ b/ferro-ta-main/src/pattern/cdltakuri.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdltakuri<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdltasukigap.rs b/ferro-ta-main/src/pattern/cdltasukigap.rs new file mode 100644 index 0000000..640e690 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdltasukigap.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdltasukigap<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlthrusting.rs b/ferro-ta-main/src/pattern/cdlthrusting.rs new file mode 100644 index 0000000..4b01824 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlthrusting.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlthrusting<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdltristar.rs b/ferro-ta-main/src/pattern/cdltristar.rs new file mode 100644 index 0000000..53311fa --- /dev/null +++ b/ferro-ta-main/src/pattern/cdltristar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdltristar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlunique3river.rs b/ferro-ta-main/src/pattern/cdlunique3river.rs new file mode 100644 index 0000000..379cd5f --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlunique3river.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlunique3river<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlupsidegap2crows.rs b/ferro-ta-main/src/pattern/cdlupsidegap2crows.rs new file mode 100644 index 0000000..18cce70 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlupsidegap2crows.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlupsidegap2crows<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/cdlxsidegap3methods.rs b/ferro-ta-main/src/pattern/cdlxsidegap3methods.rs new file mode 100644 index 0000000..3664f92 --- /dev/null +++ b/ferro-ta-main/src/pattern/cdlxsidegap3methods.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlxsidegap3methods<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + 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/ferro-ta-main/src/pattern/common.rs b/ferro-ta-main/src/pattern/common.rs new file mode 100644 index 0000000..9b12440 --- /dev/null +++ b/ferro-ta-main/src/pattern/common.rs @@ -0,0 +1 @@ +// Common helpers are now in ferro_ta_core::pattern. This file is kept for mod declaration. diff --git a/ferro-ta-main/src/pattern/mod.rs b/ferro-ta-main/src/pattern/mod.rs new file mode 100644 index 0000000..c6acb01 --- /dev/null +++ b/ferro-ta-main/src/pattern/mod.rs @@ -0,0 +1,243 @@ +//! Candlestick pattern recognition (CDL*). One module per pattern for maintainability. + +mod common; + +mod cdl2crows; +mod cdl3blackcrows; +mod cdl3inside; +mod cdl3linestrike; +mod cdl3outside; +mod cdl3starsinsouth; +mod cdl3whitesoldiers; +mod cdlabandonedbaby; +mod cdladvanceblock; +mod cdlbelthold; +mod cdlbreakaway; +mod cdlclosingmarubozu; +mod cdlconcealbabyswall; +mod cdlcounterattack; +mod cdldarkcloudcover; +mod cdldoji; +mod cdldojistar; +mod cdldragonflydoji; +mod cdlengulfing; +mod cdleveningdojistar; +mod cdleveningstar; +mod cdlgapsidesidewhite; +mod cdlgravestonedoji; +mod cdlhammer; +mod cdlhangingman; +mod cdlharami; +mod cdlharamicross; +mod cdlhighwave; +mod cdlhikkake; +mod cdlhikkakemod; +mod cdlhomingpigeon; +mod cdlidentical3crows; +mod cdlinneck; +mod cdlinvertedhammer; +mod cdlkicking; +mod cdlkickingbylength; +mod cdlladderbottom; +mod cdllongleggeddoji; +mod cdllongline; +mod cdlmarubozu; +mod cdlmatchinglow; +mod cdlmathold; +mod cdlmorningdojistar; +mod cdlmorningstar; +mod cdlonneck; +mod cdlpiercing; +mod cdlrickshawman; +mod cdlrisefall3methods; +mod cdlseparatinglines; +mod cdlshootingstar; +mod cdlshortline; +mod cdlspinningtop; +mod cdlstalledpattern; +mod cdlsticksandwich; +mod cdltakuri; +mod cdltasukigap; +mod cdlthrusting; +mod cdltristar; +mod cdlunique3river; +mod cdlupsidegap2crows; +mod cdlxsidegap3methods; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::cdldoji::cdldoji, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlengulfing::cdlengulfing, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlhammer::cdlhammer, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlshootingstar::cdlshootingstar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlmarubozu::cdlmarubozu, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlspinningtop::cdlspinningtop, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlmorningstar::cdlmorningstar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdleveningstar::cdleveningstar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdl2crows::cdl2crows, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdl3blackcrows::cdl3blackcrows, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdl3whitesoldiers::cdl3whitesoldiers, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdl3inside::cdl3inside, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdl3outside::cdl3outside, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdldojistar::cdldojistar, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlmorningdojistar::cdlmorningdojistar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdleveningdojistar::cdleveningdojistar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlharami::cdlharami, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlharamicross::cdlharamicross, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdl3linestrike::cdl3linestrike, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdl3starsinsouth::cdl3starsinsouth, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlabandonedbaby::cdlabandonedbaby, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdladvanceblock::cdladvanceblock, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlbelthold::cdlbelthold, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlbreakaway::cdlbreakaway, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlclosingmarubozu::cdlclosingmarubozu, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlconcealbabyswall::cdlconcealbabyswall, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlcounterattack::cdlcounterattack, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdldarkcloudcover::cdldarkcloudcover, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdldragonflydoji::cdldragonflydoji, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlgapsidesidewhite::cdlgapsidesidewhite, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlgravestonedoji::cdlgravestonedoji, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlhangingman::cdlhangingman, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlhighwave::cdlhighwave, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlhikkake::cdlhikkake, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlhikkakemod::cdlhikkakemod, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlhomingpigeon::cdlhomingpigeon, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlidentical3crows::cdlidentical3crows, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlinneck::cdlinneck, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlinvertedhammer::cdlinvertedhammer, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlkicking::cdlkicking, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlkickingbylength::cdlkickingbylength, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlladderbottom::cdlladderbottom, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdllongleggeddoji::cdllongleggeddoji, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdllongline::cdllongline, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlmatchinglow::cdlmatchinglow, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlmathold::cdlmathold, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlonneck::cdlonneck, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlpiercing::cdlpiercing, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlrickshawman::cdlrickshawman, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlrisefall3methods::cdlrisefall3methods, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlseparatinglines::cdlseparatinglines, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlshortline::cdlshortline, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlstalledpattern::cdlstalledpattern, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlsticksandwich::cdlsticksandwich, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdltakuri::cdltakuri, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdltasukigap::cdltasukigap, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlthrusting::cdlthrusting, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdltristar::cdltristar, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlunique3river::cdlunique3river, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlupsidegap2crows::cdlupsidegap2crows, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlxsidegap3methods::cdlxsidegap3methods, + m + )?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/portfolio/mod.rs b/ferro-ta-main/src/portfolio/mod.rs new file mode 100644 index 0000000..8ee9402 --- /dev/null +++ b/ferro-ta-main/src/portfolio/mod.rs @@ -0,0 +1,371 @@ +//! 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 +//! - `beta_series` — rolling or full beta of asset vs benchmark +//! - `drawdown_series` — per-bar drawdown and underwater series +//! - `correlation_matrix` — pairwise Pearson correlation (n_assets × n_assets) +//! - `relative_strength` — cumulative return ratio (asset / benchmark) +//! - `spread` — A - hedge * B +//! - `zscore_series` — rolling Z-score of a 1-D series +//! - `rolling_beta` — rolling beta (hedge ratio) of two series + +use ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +// --------------------------------------------------------------------------- +// portfolio_volatility +// --------------------------------------------------------------------------- + +/// Compute portfolio volatility: sqrt(w' Σ w). +/// +/// Parameters +/// ---------- +/// cov_matrix : 2-D float64 array, shape (n, n) — covariance matrix +/// weights : 1-D float64 array, length n — portfolio weights (need not sum to 1) +/// +/// Returns +/// ------- +/// float — portfolio volatility (annualisation is the caller's responsibility) +#[pyfunction] +pub fn portfolio_volatility<'py>( + cov_matrix: PyReadonlyArray2<'py, f64>, + weights: PyReadonlyArray1<'py, f64>, +) -> PyResult { + let cov = cov_matrix.as_array(); + let w = weights.as_slice()?; + let n = w.len(); + let (rows, cols) = cov.dim(); + if rows != n || cols != n { + return Err(PyValueError::new_err(format!( + "cov_matrix must be ({n}, {n}), got ({rows}, {cols})" + ))); + } + // 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)) +} + +// --------------------------------------------------------------------------- +// beta_full +// --------------------------------------------------------------------------- + +/// Compute the full-sample beta of `asset_returns` to `benchmark_returns`. +/// +/// Beta = Cov(asset, bench) / Var(bench) (OLS regression slope). +/// +/// Parameters +/// ---------- +/// asset_returns, benchmark_returns : 1-D float64 arrays (equal length, >= 2 elements) +/// +/// Returns +/// ------- +/// float — beta +#[pyfunction] +pub fn beta_full<'py>( + asset_returns: PyReadonlyArray1<'py, f64>, + benchmark_returns: PyReadonlyArray1<'py, f64>, +) -> PyResult { + let a = asset_returns.as_slice()?; + let b = benchmark_returns.as_slice()?; + let n = a.len(); + if n < 2 || b.len() != n { + return Err(PyValueError::new_err( + "asset_returns and benchmark_returns must have equal length >= 2", + )); + } + Ok(ferro_ta_core::portfolio::beta_full(a, b)) +} + +// --------------------------------------------------------------------------- +// rolling_beta +// --------------------------------------------------------------------------- + +/// Compute rolling beta of `asset` vs `benchmark` over a sliding window. +/// +/// Parameters +/// ---------- +/// asset, benchmark : 1-D float64 arrays (equal length) +/// window : int — rolling window size (must be >= 2) +/// +/// Returns +/// ------- +/// 1-D float64 array — NaN for first `window-1` positions. +#[pyfunction] +#[pyo3(signature = (asset, benchmark, window))] +pub fn rolling_beta<'py>( + py: Python<'py>, + asset: PyReadonlyArray1<'py, f64>, + benchmark: PyReadonlyArray1<'py, f64>, + window: usize, +) -> PyResult>> { + if window < 2 { + return Err(PyValueError::new_err("window must be >= 2")); + } + let a = asset.as_slice()?; + let b = benchmark.as_slice()?; + let n = a.len(); + if n == 0 || b.len() != n { + return Err(PyValueError::new_err( + "asset and benchmark must be non-empty and equal length", + )); + } + let result = ferro_ta_core::portfolio::rolling_beta(a, b, window); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// 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). +/// +/// Parameters +/// ---------- +/// equity : 1-D float64 array — equity or price series +/// +/// Returns +/// ------- +/// (drawdown, max_drawdown) +/// drawdown : 1-D float64 array (same length as equity) +/// max_drawdown : float — worst (most negative) drawdown observed +#[pyfunction] +pub fn drawdown_series<'py>( + py: Python<'py>, + equity: PyReadonlyArray1<'py, f64>, +) -> PyResult<(Bound<'py, PyArray1>, f64)> { + let eq = equity.as_slice()?; + if eq.is_empty() { + return Err(PyValueError::new_err("equity must be non-empty")); + } + let (dd, max_dd) = ferro_ta_core::portfolio::drawdown_series(eq); + Ok((dd.into_pyarray(py), max_dd)) +} + +// --------------------------------------------------------------------------- +// correlation_matrix +// --------------------------------------------------------------------------- + +/// Compute the pairwise Pearson correlation matrix for a returns DataFrame. +/// +/// Parameters +/// ---------- +/// data : 2-D float64 array, shape (n_bars, n_assets) — returns per bar/asset +/// +/// Returns +/// ------- +/// 2-D float64 array, shape (n_assets, n_assets) — correlation matrix +#[pyfunction] +pub fn correlation_matrix<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, +) -> PyResult>> { + let arr = data.as_array(); + let (n_bars, n_assets) = arr.dim(); + 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)); + for j1 in 0..n_assets { + for j2 in 0..n_assets { + result[[j1, j2]] = corr[j1][j2]; + } + } + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// relative_strength +// --------------------------------------------------------------------------- + +/// Compute relative strength of an asset vs a benchmark. +/// +/// result[i] = (1 + asset_returns[i]).cumprod() / (1 + bench_returns[i]).cumprod() +/// starting from 1.0. +/// +/// Parameters +/// ---------- +/// asset_returns, benchmark_returns : 1-D float64 arrays (equal length) +/// Fractional returns per bar (e.g. 0.01 for +1%). +/// +/// Returns +/// ------- +/// 1-D float64 array — relative strength (ratio of cumulative returns). +#[pyfunction] +pub fn relative_strength<'py>( + py: Python<'py>, + asset_returns: PyReadonlyArray1<'py, f64>, + benchmark_returns: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let a = asset_returns.as_slice()?; + let b = benchmark_returns.as_slice()?; + let n = a.len(); + if n == 0 || b.len() != n { + return Err(PyValueError::new_err( + "asset_returns and benchmark_returns must be non-empty and equal length", + )); + } + let result = ferro_ta_core::portfolio::relative_strength(a, b); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// spread +// --------------------------------------------------------------------------- + +/// Compute the spread between two series: A - hedge * B. +/// +/// Parameters +/// ---------- +/// a, b : 1-D float64 arrays (equal length) +/// hedge : float — hedge ratio (default 1.0) +/// +/// Returns +/// ------- +/// 1-D float64 array +#[pyfunction] +#[pyo3(signature = (a, b, hedge = 1.0))] +pub fn spread<'py>( + py: Python<'py>, + a: PyReadonlyArray1<'py, f64>, + b: PyReadonlyArray1<'py, f64>, + hedge: f64, +) -> PyResult>> { + let av = a.as_slice()?; + let bv = b.as_slice()?; + let n = av.len(); + if n == 0 || bv.len() != n { + return Err(PyValueError::new_err( + "a and b must be non-empty and equal length", + )); + } + let result = ferro_ta_core::portfolio::spread(av, bv, hedge); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// ratio +// --------------------------------------------------------------------------- + +/// Compute the ratio between two series: A / B. +/// +/// Where B is 0, returns NaN. +#[pyfunction] +pub fn ratio<'py>( + py: Python<'py>, + a: PyReadonlyArray1<'py, f64>, + b: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let av = a.as_slice()?; + let bv = b.as_slice()?; + let n = av.len(); + if n == 0 || bv.len() != n { + return Err(PyValueError::new_err( + "a and b must be non-empty and equal length", + )); + } + let result = ferro_ta_core::portfolio::ratio(av, bv); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// zscore_series +// --------------------------------------------------------------------------- + +/// Compute the rolling Z-score of a 1-D series. +/// +/// Z[i] = (x[i] - mean(x[i-window+1..=i])) / std(x[i-window+1..=i]) +/// +/// Parameters +/// ---------- +/// x : 1-D float64 array +/// window : int — rolling window (must be >= 2) +/// +/// Returns +/// ------- +/// 1-D float64 array — NaN for first `window-1` positions. +#[pyfunction] +#[pyo3(signature = (x, window))] +pub fn zscore_series<'py>( + py: Python<'py>, + x: PyReadonlyArray1<'py, f64>, + window: usize, +) -> PyResult>> { + if window < 2 { + return Err(PyValueError::new_err("window must be >= 2")); + } + let xv = x.as_slice()?; + if xv.is_empty() { + return Err(PyValueError::new_err("x must be non-empty")); + } + let result = ferro_ta_core::portfolio::zscore_series(xv, window); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// compose_weighted +// --------------------------------------------------------------------------- + +/// Weighted combination of multiple signal columns. +/// +/// Parameters +/// ---------- +/// data : 2-D float64 array, shape (n_bars, n_signals) +/// weights : 1-D float64 array, length n_signals +/// +/// Returns +/// ------- +/// 1-D float64 array — weighted sum per bar +#[pyfunction] +pub fn compose_weighted<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + weights: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let arr = data.as_array(); + let w = weights.as_slice()?; + let (n_bars, n_sigs) = arr.dim(); + if w.len() != n_sigs { + return Err(PyValueError::new_err(format!( + "weights length ({}) must equal number of signal columns ({})", + w.len(), + n_sigs + ))); + } + // 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)) +} + +// --------------------------------------------------------------------------- +// Register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(portfolio_volatility, m)?)?; + m.add_function(wrap_pyfunction!(beta_full, m)?)?; + m.add_function(wrap_pyfunction!(rolling_beta, m)?)?; + m.add_function(wrap_pyfunction!(drawdown_series, m)?)?; + m.add_function(wrap_pyfunction!(correlation_matrix, m)?)?; + m.add_function(wrap_pyfunction!(relative_strength, m)?)?; + m.add_function(wrap_pyfunction!(spread, m)?)?; + m.add_function(wrap_pyfunction!(ratio, m)?)?; + m.add_function(wrap_pyfunction!(zscore_series, m)?)?; + m.add_function(wrap_pyfunction!(compose_weighted, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/price_transform/avgprice.rs b/ferro-ta-main/src/price_transform/avgprice.rs new file mode 100644 index 0000000..c892553 --- /dev/null +++ b/ferro-ta-main/src/price_transform/avgprice.rs @@ -0,0 +1,27 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Average Price: (open + high + low + close) / 4. +#[pyfunction] +pub fn avgprice<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + 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(); + validation::validate_equal_length(&[ + (n, "open"), + (highs.len(), "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::price_transform::avgprice(opens, highs, lows, closes); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/price_transform/medprice.rs b/ferro-ta-main/src/price_transform/medprice.rs new file mode 100644 index 0000000..d47356b --- /dev/null +++ b/ferro-ta-main/src/price_transform/medprice.rs @@ -0,0 +1,18 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Median Price: (high + low) / 2. +#[pyfunction] +pub fn medprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + let result = ferro_ta_core::price_transform::medprice(highs, lows); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/price_transform/mod.rs b/ferro-ta-main/src/price_transform/mod.rs new file mode 100644 index 0000000..7aadfd1 --- /dev/null +++ b/ferro-ta-main/src/price_transform/mod.rs @@ -0,0 +1,17 @@ +//! Price transformations — helper functions to synthesize OHLC arrays into single price arrays. +//! Each transform lives in its own file for maintainability. + +mod avgprice; +mod medprice; +mod typprice; +mod wclprice; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::avgprice::avgprice, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::medprice::medprice, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::typprice::typprice, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::wclprice::wclprice, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/price_transform/typprice.rs b/ferro-ta-main/src/price_transform/typprice.rs new file mode 100644 index 0000000..7ac07ac --- /dev/null +++ b/ferro-ta-main/src/price_transform/typprice.rs @@ -0,0 +1,24 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Typical Price: (high + low + close) / 3. +#[pyfunction] +pub fn typprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::price_transform::typprice(highs, lows, closes); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/price_transform/wclprice.rs b/ferro-ta-main/src/price_transform/wclprice.rs new file mode 100644 index 0000000..ae3d466 --- /dev/null +++ b/ferro-ta-main/src/price_transform/wclprice.rs @@ -0,0 +1,24 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Weighted Close Price: (high + low + close * 2) / 4. +#[pyfunction] +pub fn wclprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::price_transform::wclprice(highs, lows, closes); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/regime/mod.rs b/ferro-ta-main/src/regime/mod.rs new file mode 100644 index 0000000..22e8445 --- /dev/null +++ b/ferro-ta-main/src/regime/mod.rs @@ -0,0 +1,80 @@ +//! 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::*; + +use crate::validation; + +/// Label each bar as **trend** (1) or **range** (0) based on ADX level. +#[pyfunction] +pub fn regime_adx<'py>( + py: Python<'py>, + adx: PyReadonlyArray1<'py, f64>, + threshold: f64, +) -> PyResult>> { + let a = adx.as_slice()?; + let result = ferro_ta_core::regime::regime_adx(a, threshold); + Ok(result.into_pyarray(py)) +} + +/// Label each bar as trend (1) or range (0) using ADX + ATR-ratio rule. +#[pyfunction] +pub fn regime_combined<'py>( + py: Python<'py>, + adx: PyReadonlyArray1<'py, f64>, + atr: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + adx_threshold: f64, + atr_pct_threshold: f64, +) -> PyResult>> { + let a = adx.as_slice()?; + let r = atr.as_slice()?; + let c = close.as_slice()?; + let n = a.len(); + 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 structural breaks using a CUSUM approach. +#[pyfunction] +pub fn detect_breaks_cusum<'py>( + py: Python<'py>, + series: PyReadonlyArray1<'py, f64>, + window: usize, + threshold: f64, + slack: f64, +) -> PyResult>> { + validation::validate_timeperiod(window, "window", 2)?; + let s = series.as_slice()?; + let result = ferro_ta_core::regime::detect_breaks_cusum(s, window, threshold, slack); + Ok(result.into_pyarray(py)) +} + +/// Detect volatility regime breaks using rolling variance ratio. +#[pyfunction] +pub fn rolling_variance_break<'py>( + py: Python<'py>, + series: PyReadonlyArray1<'py, f64>, + short_window: usize, + long_window: usize, + threshold: f64, +) -> PyResult>> { + validation::validate_timeperiod(short_window, "short_window", 2)?; + if long_window <= short_window { + return Err(PyValueError::new_err("long_window must be > short_window")); + } + let s = series.as_slice()?; + let result = + ferro_ta_core::regime::rolling_variance_break(s, short_window, long_window, threshold); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(regime_adx, m)?)?; + m.add_function(wrap_pyfunction!(regime_combined, m)?)?; + m.add_function(wrap_pyfunction!(detect_breaks_cusum, m)?)?; + m.add_function(wrap_pyfunction!(rolling_variance_break, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/resampling/mod.rs b/ferro-ta-main/src/resampling/mod.rs new file mode 100644 index 0000000..4b99deb --- /dev/null +++ b/ferro-ta-main/src/resampling/mod.rs @@ -0,0 +1,90 @@ +//! Resampling — OHLCV resampling (thin PyO3 wrapper over ferro_ta_core::resampling). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +type Ohlcv5<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +/// Aggregate OHLCV data into volume bars of a fixed volume threshold. +#[pyfunction] +#[pyo3(signature = (open, high, low, close, volume, volume_threshold))] +pub fn volume_bars<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + volume_threshold: f64, +) -> PyResult> { + if volume_threshold <= 0.0 { + return Err(PyValueError::new_err("volume_threshold must be > 0")); + } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let v = volume.as_slice()?; + let n = o.len(); + if n == 0 || h.len() != n || l.len() != n || c.len() != n || v.len() != n { + return Err(PyValueError::new_err( + "All input arrays must be non-empty and have equal length", + )); + } + let (ro, rh, rl, rc, rv) = + ferro_ta_core::resampling::volume_bars(o, h, l, c, v, volume_threshold); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + )) +} + +/// Aggregate OHLCV bars by integer group labels. +#[pyfunction] +#[pyo3(signature = (open, high, low, close, volume, labels))] +pub fn ohlcv_agg<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + labels: PyReadonlyArray1<'py, i64>, +) -> PyResult> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let v = volume.as_slice()?; + let lbl = labels.as_slice()?; + let n = o.len(); + if n == 0 || h.len() != n || l.len() != n || c.len() != n || v.len() != n || lbl.len() != n { + return Err(PyValueError::new_err( + "All input arrays must be non-empty and have equal length", + )); + } + let (ro, rh, rl, rc, rv) = ferro_ta_core::resampling::ohlcv_agg(o, h, l, c, v, lbl); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + )) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(volume_bars, m)?)?; + m.add_function(wrap_pyfunction!(ohlcv_agg, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/signals/mod.rs b/ferro-ta-main/src/signals/mod.rs new file mode 100644 index 0000000..0a1a845 --- /dev/null +++ b/ferro-ta-main/src/signals/mod.rs @@ -0,0 +1,78 @@ +//! Signal processing helpers (thin PyO3 wrapper over ferro_ta_core::signals). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Compute the fractional rank of each element (1-based, ascending). +/// 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()?; + if xv.is_empty() { + return Err(PyValueError::new_err("x must be non-empty")); + } + let result = ferro_ta_core::signals::rank_values(xv); + Ok(result.into_pyarray(py)) +} + +/// Compute rank-based composite scores for a 2-D signal matrix. +/// Each column is ranked independently, per-row ranks are summed. +#[pyfunction] +pub fn compose_rank<'py>( + py: Python<'py>, + signals: PyReadonlyArray2<'py, f64>, +) -> PyResult>> { + let arr = signals.as_array(); + let (n_bars, n_sigs) = arr.dim(); + if n_bars == 0 || n_sigs == 0 { + return Err(PyValueError::new_err( + "signals must be a non-empty 2-D array", + )); + } + + let scores = py.allow_threads(|| { + 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)) +} + +/// Return the indices of the N largest values in `x`. +#[pyfunction] +pub fn top_n_indices<'py>( + py: Python<'py>, + x: PyReadonlyArray1<'py, f64>, + n: usize, +) -> PyResult>> { + let xv = x.as_slice()?; + let result = ferro_ta_core::signals::top_n_indices(xv, n); + Ok(result.into_pyarray(py)) +} + +/// Return the indices of the N smallest values in `x`. +#[pyfunction] +pub fn bottom_n_indices<'py>( + py: Python<'py>, + x: PyReadonlyArray1<'py, f64>, + n: usize, +) -> PyResult>> { + let xv = x.as_slice()?; + let result = ferro_ta_core::signals::bottom_n_indices(xv, n); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(rank_series, m)?)?; + m.add_function(wrap_pyfunction!(compose_rank, m)?)?; + m.add_function(wrap_pyfunction!(top_n_indices, m)?)?; + m.add_function(wrap_pyfunction!(bottom_n_indices, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/statistic/beta.rs b/ferro-ta-main/src/statistic/beta.rs new file mode 100644 index 0000000..fc1c333 --- /dev/null +++ b/ferro-ta-main/src/statistic/beta.rs @@ -0,0 +1,146 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +fn price_return(curr: f64, prev: f64) -> f64 { + if prev != 0.0 { + curr / prev - 1.0 + } else { + f64::NAN + } +} + +fn beta_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec { + let n = x.len(); + let mut result = vec![f64::NAN; n]; + for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod) { + let start = end - timeperiod; + let mut rx = vec![0.0_f64; timeperiod]; + let mut ry = vec![0.0_f64; timeperiod]; + for offset in 0..timeperiod { + let prev = start + offset; + let curr = prev + 1; + rx[offset] = price_return(x[curr], x[prev]); + ry[offset] = price_return(y[curr], y[prev]); + } + let mean_x = rx.iter().sum::() / timeperiod as f64; + let mean_y = ry.iter().sum::() / timeperiod as f64; + let cov = rx + .iter() + .zip(ry.iter()) + .map(|(&lhs, &rhs)| (lhs - mean_x) * (rhs - mean_y)) + .sum::() + / timeperiod as f64; + let var_x = rx + .iter() + .map(|&value| (value - mean_x).powi(2)) + .sum::() + / timeperiod as f64; + *slot = if var_x != 0.0 { cov / var_x } else { f64::NAN }; + } + result +} + +/// Beta: regression of *real1* daily returns on *real0* daily returns over a +/// rolling window of *timeperiod* return pairs. +/// +/// Matches TA-Lib's algorithm: +/// - For bar *i* (output index *i*): use `timeperiod` pairs of consecutive +/// price returns from the window ending at bar *i*. +/// - Return for bar t: r_x[t] = x[t]/x[t-1] - 1 (similarly for y). +/// - beta = Cov(r_y, r_x) / Var(r_x) (sample, divided by timeperiod). +/// - First valid output is at index `timeperiod` (needs `timeperiod+1` bars). +#[pyfunction] +#[pyo3(signature = (real0, real1, timeperiod = 5))] +pub fn beta<'py>( + py: Python<'py>, + real0: PyReadonlyArray1<'py, f64>, + real1: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let x = real0.as_slice()?; + let y = real1.as_slice()?; + let n = x.len(); + validation::validate_equal_length(&[(n, "real0"), (y.len(), "real1")])?; + + if x.iter().any(|value| !value.is_finite()) || y.iter().any(|value| !value.is_finite()) { + return Ok(beta_fallback(x, y, timeperiod).into_pyarray(py)); + } + + let mut result = vec![f64::NAN; n]; + if n <= timeperiod { + return Ok(result.into_pyarray(py)); + } + + let rx: Vec = x + .windows(2) + .map(|window| price_return(window[1], window[0])) + .collect(); + let ry: Vec = y + .windows(2) + .map(|window| price_return(window[1], window[0])) + .collect(); + + let period = timeperiod as f64; + let mut invalid_pairs = 0_usize; + let mut sum_rx = 0.0_f64; + let mut sum_ry = 0.0_f64; + let mut sum_rx2 = 0.0_f64; + let mut sum_rxry = 0.0_f64; + + for idx in 0..timeperiod { + let ret_x = rx[idx]; + let ret_y = ry[idx]; + if ret_x.is_finite() && ret_y.is_finite() { + sum_rx += ret_x; + sum_ry += ret_y; + sum_rx2 += ret_x * ret_x; + sum_rxry += ret_x * ret_y; + } else { + invalid_pairs += 1; + } + } + + for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod) { + *slot = if invalid_pairs == 0 { + let denom = period * sum_rx2 - sum_rx * sum_rx; + if denom != 0.0 { + (period * sum_rxry - sum_rx * sum_ry) / denom + } else { + f64::NAN + } + } else { + f64::NAN + }; + + if end + 1 < n { + let outgoing = end - timeperiod; + let incoming = end; + + let outgoing_x = rx[outgoing]; + let outgoing_y = ry[outgoing]; + if outgoing_x.is_finite() && outgoing_y.is_finite() { + sum_rx -= outgoing_x; + sum_ry -= outgoing_y; + sum_rx2 -= outgoing_x * outgoing_x; + sum_rxry -= outgoing_x * outgoing_y; + } else { + invalid_pairs -= 1; + } + + let incoming_x = rx[incoming]; + let incoming_y = ry[incoming]; + if incoming_x.is_finite() && incoming_y.is_finite() { + sum_rx += incoming_x; + sum_ry += incoming_y; + sum_rx2 += incoming_x * incoming_x; + sum_rxry += incoming_x * incoming_y; + } else { + invalid_pairs += 1; + } + } + } + + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/statistic/common.rs b/ferro-ta-main/src/statistic/common.rs new file mode 100644 index 0000000..d833554 --- /dev/null +++ b/ferro-ta-main/src/statistic/common.rs @@ -0,0 +1,70 @@ +/// Rolling linear regression: returns (slope, intercept) for the given window. +pub(super) 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) +} + +pub(crate) 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 +} diff --git a/ferro-ta-main/src/statistic/correl.rs b/ferro-ta-main/src/statistic/correl.rs new file mode 100644 index 0000000..0070569 --- /dev/null +++ b/ferro-ta-main/src/statistic/correl.rs @@ -0,0 +1,101 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +fn correl_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec { + let n = x.len(); + let mut result = vec![f64::NAN; n]; + for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod - 1) { + let wx = &x[(end + 1 - timeperiod)..=end]; + let wy = &y[(end + 1 - timeperiod)..=end]; + let mean_x = wx.iter().sum::() / timeperiod as f64; + let mean_y = wy.iter().sum::() / timeperiod as f64; + let cov = wx + .iter() + .zip(wy.iter()) + .map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y)) + .sum::(); + let std_x = wx + .iter() + .map(|&xi| (xi - mean_x).powi(2)) + .sum::() + .sqrt(); + let std_y = wy + .iter() + .map(|&yi| (yi - mean_y).powi(2)) + .sum::() + .sqrt(); + let denom = std_x * std_y; + *slot = if denom != 0.0 { cov / denom } else { f64::NAN }; + } + result +} + +/// Pearson correlation coefficient between two series over the rolling window. +#[pyfunction] +#[pyo3(signature = (real0, real1, timeperiod = 30))] +pub fn correl<'py>( + py: Python<'py>, + real0: PyReadonlyArray1<'py, f64>, + real1: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let x = real0.as_slice()?; + let y = real1.as_slice()?; + let n = x.len(); + validation::validate_equal_length(&[(n, "real0"), (y.len(), "real1")])?; + + if x.iter().any(|value| !value.is_finite()) || y.iter().any(|value| !value.is_finite()) { + return Ok(correl_fallback(x, y, timeperiod).into_pyarray(py)); + } + + let mut result = vec![f64::NAN; n]; + if n < timeperiod { + return Ok(result.into_pyarray(py)); + } + + let period = timeperiod as f64; + let mut sum_x = x[..timeperiod].iter().sum::(); + let mut sum_y = y[..timeperiod].iter().sum::(); + let mut sum_x2 = x[..timeperiod] + .iter() + .map(|value| value * value) + .sum::(); + let mut sum_y2 = y[..timeperiod] + .iter() + .map(|value| value * value) + .sum::(); + let mut sum_xy = x[..timeperiod] + .iter() + .zip(y[..timeperiod].iter()) + .map(|(&lhs, &rhs)| lhs * rhs) + .sum::(); + + for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod - 1) { + let denom_x = period * sum_x2 - sum_x * sum_x; + let denom_y = period * sum_y2 - sum_y * sum_y; + *slot = if denom_x > 0.0 && denom_y > 0.0 { + (period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt() + } else { + f64::NAN + }; + + if end + 1 < n { + let outgoing = end + 1 - timeperiod; + let incoming = end + 1; + + let outgoing_x = x[outgoing]; + let outgoing_y = y[outgoing]; + let incoming_x = x[incoming]; + let incoming_y = y[incoming]; + + sum_x += incoming_x - outgoing_x; + sum_y += incoming_y - outgoing_y; + sum_x2 += incoming_x * incoming_x - outgoing_x * outgoing_x; + sum_y2 += incoming_y * incoming_y - outgoing_y * outgoing_y; + sum_xy += incoming_x * incoming_y - outgoing_x * outgoing_y; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/statistic/dtw.rs b/ferro-ta-main/src/statistic/dtw.rs new file mode 100644 index 0000000..30b469b --- /dev/null +++ b/ferro-ta-main/src/statistic/dtw.rs @@ -0,0 +1,98 @@ +use ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use rayon::prelude::*; + +/// Dynamic Time Warping — distance and optimal warping path between two 1-D series. +/// +/// Returns a tuple `(distance, path)` where `path` is a NumPy array of shape +/// `(N, 2)` containing `(i, j)` index pairs from `(0, 0)` to `(n-1, m-1)`. +/// +/// Local cost: `|series1[i] - series2[j]|` (Euclidean, matches `dtaidistance`). +#[pyfunction] +#[pyo3(signature = (series1, series2, window = None))] +pub fn dtw<'py>( + py: Python<'py>, + series1: PyReadonlyArray1<'py, f64>, + series2: PyReadonlyArray1<'py, f64>, + window: Option, +) -> PyResult<(f64, Bound<'py, PyArray2>)> { + let s1 = series1.as_slice()?; + let s2 = series2.as_slice()?; + if s1.is_empty() || s2.is_empty() { + return Err(PyValueError::new_err( + "series1 and series2 must not be empty", + )); + } + let (dist, path) = ferro_ta_core::statistic::dtw_path(s1, s2, window); + let n = path.len(); + let flat: Vec = path.iter().flat_map(|&(i, j)| [i, j]).collect(); + let arr = + Array2::from_shape_vec((n, 2), flat).map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok((dist, arr.into_pyarray(py))) +} + +/// Dynamic Time Warping — distance only (faster, no path reconstruction). +/// +/// Returns the accumulated Euclidean cost along the optimal warping path. +/// Use this when you only need the distance, not the alignment path. +#[pyfunction] +#[pyo3(signature = (series1, series2, window = None))] +pub fn dtw_distance<'py>( + _py: Python<'py>, + series1: PyReadonlyArray1<'py, f64>, + series2: PyReadonlyArray1<'py, f64>, + window: Option, +) -> PyResult { + let s1 = series1.as_slice()?; + let s2 = series2.as_slice()?; + if s1.is_empty() || s2.is_empty() { + return Err(PyValueError::new_err( + "series1 and series2 must not be empty", + )); + } + Ok(ferro_ta_core::statistic::dtw_distance(s1, s2, window)) +} + +/// Batch Dynamic Time Warping — compute DTW distance from each row of a 2-D matrix +/// to a single reference series, in parallel. +/// +/// Parameters +/// ---------- +/// matrix : np.ndarray, shape (N, L) +/// N time series of length L. Each row is compared against `reference`. +/// reference : np.ndarray, shape (L,) +/// The reference series. +/// window : int, optional +/// Sakoe-Chiba band width. `None` = unconstrained. +/// +/// Returns +/// ------- +/// np.ndarray, shape (N,) +/// DTW distances, one per row. +#[pyfunction] +#[pyo3(signature = (matrix, reference, window = None))] +pub fn batch_dtw<'py>( + py: Python<'py>, + matrix: PyReadonlyArray2<'py, f64>, + reference: PyReadonlyArray1<'py, f64>, + window: Option, +) -> PyResult>> { + let mat = matrix.as_array(); + let ref_slice = reference.as_slice()?; + + if ref_slice.is_empty() { + return Err(PyValueError::new_err("reference must not be empty")); + } + + let (n_rows, _) = mat.dim(); + let rows: Vec> = (0..n_rows).map(|i| mat.row(i).to_vec()).collect(); + + let result: Vec = rows + .par_iter() + .map(|series| ferro_ta_core::statistic::dtw_distance(series, ref_slice, window)) + .collect(); + + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/statistic/linearreg.rs b/ferro-ta-main/src/statistic/linearreg.rs new file mode 100644 index 0000000..e22e7ec --- /dev/null +++ b/ferro-ta-main/src/statistic/linearreg.rs @@ -0,0 +1,81 @@ +use super::common::rolling_linreg_apply; +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; +use std::f64::consts::PI; + +/// Linear regression fitted value at the last point of the window. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn linearreg<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let last_x = (timeperiod - 1) as f64; + let result = rolling_linreg_apply(prices, timeperiod, |slope, intercept| { + intercept + slope * last_x + }); + Ok(result.into_pyarray(py)) +} + +/// Slope of the rolling linear regression line. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn linearreg_slope<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let result = rolling_linreg_apply(prices, timeperiod, |slope, _| slope); + Ok(result.into_pyarray(py)) +} + +/// Intercept of the rolling linear regression line. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn linearreg_intercept<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let result = rolling_linreg_apply(prices, timeperiod, |_, intercept| intercept); + Ok(result.into_pyarray(py)) +} + +/// Angle of the regression line in degrees (atan(slope) * 180/π). +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn linearreg_angle<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let result = rolling_linreg_apply(prices, timeperiod, |slope, _| slope.atan() * 180.0 / PI); + Ok(result.into_pyarray(py)) +} + +/// Time series forecast: linear regression extrapolated one period ahead. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn tsf<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let forecast_x = timeperiod as f64; + let result = rolling_linreg_apply(prices, timeperiod, |slope, intercept| { + intercept + slope * forecast_x + }); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/statistic/mod.rs b/ferro-ta-main/src/statistic/mod.rs new file mode 100644 index 0000000..bcef6e7 --- /dev/null +++ b/ferro-ta-main/src/statistic/mod.rs @@ -0,0 +1,31 @@ +//! Statistic functions — rolling window statistical operations on price data. +//! Each function (or closely related group) lives in its own file. + +mod beta; +pub(crate) mod common; +mod correl; +mod dtw; +mod linearreg; +mod stddev; +mod var; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::stddev::stddev, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::var::var, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::linearreg::linearreg, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::linearreg::linearreg_slope, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::linearreg::linearreg_intercept, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::linearreg::linearreg_angle, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::linearreg::tsf, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::beta::beta, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::correl::correl, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw_distance, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::dtw::batch_dtw, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/statistic/stddev.rs b/ferro-ta-main/src/statistic/stddev.rs new file mode 100644 index 0000000..d918fd3 --- /dev/null +++ b/ferro-ta-main/src/statistic/stddev.rs @@ -0,0 +1,30 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::StandardDeviation; +use ta::Next; + +/// Standard deviation over a rolling window; scaled by nbdev (default 1.0). +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 5, nbdev = 1.0))] +pub fn stddev<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + nbdev: f64, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut indicator = + StandardDeviation::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let val = indicator.next(price); + if i + 1 >= timeperiod { + result[i] = val * nbdev; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/statistic/var.rs b/ferro-ta-main/src/statistic/var.rs new file mode 100644 index 0000000..de81e12 --- /dev/null +++ b/ferro-ta-main/src/statistic/var.rs @@ -0,0 +1,26 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Rolling variance; scaled by nbdev². +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 5, nbdev = 1.0))] +pub fn var<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + nbdev: f64, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in (timeperiod - 1)..n { + let window = &prices[(i + 1 - timeperiod)..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let variance: f64 = + window.iter().map(|x| (x - mean).powi(2)).sum::() / timeperiod as f64; + result[i] = variance * nbdev * nbdev; + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/streaming/mod.rs b/ferro-ta-main/src/streaming/mod.rs new file mode 100644 index 0000000..a0b3ab5 --- /dev/null +++ b/ferro-ta-main/src/streaming/mod.rs @@ -0,0 +1,385 @@ +//! Streaming / Incremental Indicators — bar-by-bar stateful classes. +//! +//! Thin PyO3 wrappers that delegate to `ferro_ta_core::streaming`. + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use ferro_ta_core::streaming as core; + +// --------------------------------------------------------------------------- +// Helper: convert core StreamingError to PyValueError +// --------------------------------------------------------------------------- + +fn to_py_err(e: core::StreamingError) -> PyErr { + PyValueError::new_err(e.0) +} + +// --------------------------------------------------------------------------- +// StreamingSMA +// --------------------------------------------------------------------------- + +/// Simple Moving Average — O(1) per update via running sum. +/// +/// Returns NaN during the first `period - 1` bars. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingSMA { + inner: core::StreamingSMA, +} + +#[pymethods] +impl StreamingSMA { + #[new] + #[pyo3(signature = (period))] + pub fn new(period: usize) -> PyResult { + Ok(Self { + 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 { + self.inner.update(value) + } + + /// Reset state to initial condition. + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingSMA(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingEMA +// --------------------------------------------------------------------------- + +/// Exponential Moving Average with SMA seeding. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingEMA { + inner: core::StreamingEMA, +} + +#[pymethods] +impl StreamingEMA { + #[new] + #[pyo3(signature = (period))] + pub fn new(period: usize) -> PyResult { + Ok(Self { + inner: core::StreamingEMA::new(period).map_err(to_py_err)?, + }) + } + + /// 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(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingEMA(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingRSI +// --------------------------------------------------------------------------- + +/// Relative Strength Index with TA-Lib–compatible Wilder seeding. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingRSI { + inner: core::StreamingRSI, +} + +#[pymethods] +impl StreamingRSI { + #[new] + #[pyo3(signature = (period = 14))] + pub fn new(period: usize) -> PyResult { + Ok(Self { + 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 { + self.inner.update(value) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingRSI(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingATR +// --------------------------------------------------------------------------- + +/// Average True Range with TA-Lib–compatible Wilder seeding. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingATR { + inner: core::StreamingATR, +} + +#[pymethods] +impl StreamingATR { + #[new] + #[pyo3(signature = (period = 14))] + pub fn new(period: usize) -> PyResult { + Ok(Self { + inner: core::StreamingATR::new(period).map_err(to_py_err)?, + }) + } + + /// 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(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingATR(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingBBands +// --------------------------------------------------------------------------- + +/// Bollinger Bands — streaming variant using Welford's online algorithm. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingBBands { + inner: core::StreamingBBands, +} + +#[pymethods] +impl StreamingBBands { + #[new] + #[pyo3(signature = (period = 20, nbdevup = 2.0, nbdevdn = 2.0))] + pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> PyResult { + Ok(Self { + 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) { + self.inner.update(value) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingBBands(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingMACD +// --------------------------------------------------------------------------- + +/// MACD — fast EMA, slow EMA, signal EMA. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingMACD { + inner: core::StreamingMACD, +} + +#[pymethods] +impl StreamingMACD { + #[new] + #[pyo3(signature = (fastperiod = 12, slowperiod = 26, signalperiod = 9))] + pub fn new(fastperiod: usize, slowperiod: usize, signalperiod: usize) -> PyResult { + Ok(Self { + 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) { + self.inner.update(value) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + fn __repr__(&self) -> String { + format!( + "StreamingMACD(fastperiod={}, slowperiod={}, signalperiod={})", + self.inner.fast_period(), + self.inner.slow_period(), + self.inner.signal_period() + ) + } +} + +// --------------------------------------------------------------------------- +// StreamingStoch +// --------------------------------------------------------------------------- + +/// Slow Stochastic (SMA-smoothed). +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingStoch { + inner: core::StreamingStoch, +} + +#[pymethods] +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 { + Ok(Self { + 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) { + self.inner.update(high, low, close) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + fn __repr__(&self) -> String { + format!("StreamingStoch(fastk_period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingVWAP +// --------------------------------------------------------------------------- + +/// Cumulative Volume Weighted Average Price. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingVWAP { + inner: core::StreamingVWAP, +} + +impl Default for StreamingVWAP { + fn default() -> Self { + Self { + inner: core::StreamingVWAP::new(), + } + } +} + +#[pymethods] +impl StreamingVWAP { + #[new] + pub fn new() -> Self { + Self::default() + } + + /// 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 { + self.inner.update(high, low, close, volume) + } + + /// Reset for a new session. + pub fn reset(&mut self) { + self.inner.reset(); + } + + fn __repr__(&self) -> String { + "StreamingVWAP()".to_string() + } +} + +// --------------------------------------------------------------------------- +// StreamingSupertrend +// --------------------------------------------------------------------------- + +/// ATR-based Supertrend — streaming variant. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingSupertrend { + inner: core::StreamingSupertrend, +} + +#[pymethods] +impl StreamingSupertrend { + #[new] + #[pyo3(signature = (period = 7, multiplier = 3.0))] + pub fn new(period: usize, multiplier: f64) -> PyResult { + Ok(Self { + 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) { + self.inner.update(high, low, close) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingSupertrend(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/ferro-ta-main/src/validation.rs b/ferro-ta-main/src/validation.rs new file mode 100644 index 0000000..783cb89 --- /dev/null +++ b/ferro-ta-main/src/validation.rs @@ -0,0 +1,57 @@ +//! Validation helpers used by PyO3 functions. They raise PyValueError with +//! messages that match the Python check_* helpers; the Python wrapper layer +//! converts these to FerroTAValueError / FerroTAInputError via _normalize_rust_error. + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Parse a period parameter from Python (signed int). Validates >= minimum, +/// returns Ok(usize). Use this for PyO3 signatures that take `i64` so negative +/// or out-of-range values are caught in Rust with a clear error. +pub fn parse_timeperiod(value: i64, name: &str, minimum: i64) -> PyResult { + if value < minimum { + return Err(PyValueError::new_err(format!( + "{} must be >= {}, got {}", + name, minimum, value + ))); + } + let u = usize::try_from(value).map_err(|_| { + PyValueError::new_err(format!("{} must be >= {}, got {}", name, minimum, value)) + })?; + Ok(u) +} + +/// Validate that a period parameter is >= minimum. On failure raises PyValueError +/// (message format matches Python check_timeperiod; Python normalizes to FerroTAValueError). +pub fn validate_timeperiod(value: usize, name: &str, minimum: usize) -> PyResult<()> { + if value < minimum { + return Err(PyValueError::new_err(format!( + "{} must be >= {}, got {}", + name, minimum, value + ))); + } + Ok(()) +} + +/// Validate that all named lengths are equal. On failure raises PyValueError +/// (message includes "same length" so Python normalizes to FerroTAInputError). +pub fn validate_equal_length(lengths_and_names: &[(usize, &str)]) -> PyResult<()> { + if lengths_and_names.len() < 2 { + return Ok(()); + } + let first = lengths_and_names[0].0; + for (len, _name) in lengths_and_names.iter().skip(1) { + if *len != first { + let detail = lengths_and_names + .iter() + .map(|(l, n)| format!("{}={}", n, l)) + .collect::>() + .join(", "); + return Err(PyValueError::new_err(format!( + "All input arrays must have the same length. Got: {}", + detail + ))); + } + } + Ok(()) +} diff --git a/ferro-ta-main/src/volatility/atr.rs b/ferro-ta-main/src/volatility/atr.rs new file mode 100644 index 0000000..7c84ff8 --- /dev/null +++ b/ferro-ta-main/src/volatility/atr.rs @@ -0,0 +1,31 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Average True Range using TA-Lib–compatible Wilder smoothing. +/// +/// Seeding: ATR[period] = SMA of TR[1..=period] (ignoring bar-0 TR which TA-Lib also skips). +/// Subsequent values: ATR[i] = (ATR[i-1] * (period-1) + TR[i]) / period. +/// Returns NaN for indices 0 through `timeperiod - 1`. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn atr<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::volatility::atr(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/volatility/common.rs b/ferro-ta-main/src/volatility/common.rs new file mode 100644 index 0000000..7f8d914 --- /dev/null +++ b/ferro-ta-main/src/volatility/common.rs @@ -0,0 +1,17 @@ +/// Compute True Range for all bars. +/// Bar 0 uses H-L; subsequent bars use TA-Lib formula. +pub(super) fn compute_tr(highs: &[f64], lows: &[f64], closes: &[f64]) -> Vec { + let n = highs.len(); + let mut tr = vec![0.0_f64; n]; + if n == 0 { + return tr; + } + tr[0] = highs[0] - lows[0]; + for i in 1..n { + let hl = highs[i] - lows[i]; + let hpc = (highs[i] - closes[i - 1]).abs(); + let lpc = (lows[i] - closes[i - 1]).abs(); + tr[i] = hl.max(hpc).max(lpc); + } + tr +} diff --git a/ferro-ta-main/src/volatility/mod.rs b/ferro-ta-main/src/volatility/mod.rs new file mode 100644 index 0000000..2e5a233 --- /dev/null +++ b/ferro-ta-main/src/volatility/mod.rs @@ -0,0 +1,15 @@ +//! Volatility indicators — measure the magnitude of price fluctuations. +//! Each indicator lives in its own file for maintainability. + +mod atr; +mod natr; +mod trange; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::trange::trange, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::atr::atr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::natr::natr, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/volatility/natr.rs b/ferro-ta-main/src/volatility/natr.rs new file mode 100644 index 0000000..9d1a824 --- /dev/null +++ b/ferro-ta-main/src/volatility/natr.rs @@ -0,0 +1,34 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Normalized ATR: (ATR / close) * 100. Same warmup as ATR. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn natr<'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()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + // Reuse the ATR core; divide by close to get NATR (saves duplicate TR computation). + let atr_vals = ferro_ta_core::volatility::atr(highs, lows, closes, timeperiod); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + if !atr_vals[i].is_nan() && closes[i] != 0.0 { + result[i] = (atr_vals[i] / closes[i]) * 100.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/volatility/trange.rs b/ferro-ta-main/src/volatility/trange.rs new file mode 100644 index 0000000..23c18e9 --- /dev/null +++ b/ferro-ta-main/src/volatility/trange.rs @@ -0,0 +1,34 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// True Range: max(high - low, |high - prev_close|, |low - prev_close|). Bar 0 uses high - low. +#[pyfunction] +pub fn trange<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let mut result = vec![f64::NAN; n]; + if n == 0 { + return Ok(result.into_pyarray(py)); + } + result[0] = highs[0] - lows[0]; + for i in 1..n { + let hl = highs[i] - lows[i]; + let hpc = (highs[i] - closes[i - 1]).abs(); + let lpc = (lows[i] - closes[i - 1]).abs(); + result[i] = hl.max(hpc).max(lpc); + } + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/volume/ad.rs b/ferro-ta-main/src/volume/ad.rs new file mode 100644 index 0000000..995cedd --- /dev/null +++ b/ferro-ta-main/src/volume/ad.rs @@ -0,0 +1,27 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Chaikin Accumulation/Distribution Line. +#[pyfunction] +pub fn ad<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let vols = volume.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + (vols.len(), "volume"), + ])?; + let result = ferro_ta_core::volume::ad(highs, lows, closes, vols); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/volume/adosc.rs b/ferro-ta-main/src/volume/adosc.rs new file mode 100644 index 0000000..2a62874 --- /dev/null +++ b/ferro-ta-main/src/volume/adosc.rs @@ -0,0 +1,38 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Chaikin A/D Oscillator: fast EMA of AD minus slow EMA of AD. +#[pyfunction] +#[pyo3(signature = (high, low, close, volume, fastperiod = 3, slowperiod = 10))] +pub fn adosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + if fastperiod >= slowperiod { + return Err(PyValueError::new_err( + "fastperiod must be less than slowperiod", + )); + } + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let vols = volume.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + (vols.len(), "volume"), + ])?; + let result = ferro_ta_core::volume::adosc(highs, lows, closes, vols, fastperiod, slowperiod); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/src/volume/mod.rs b/ferro-ta-main/src/volume/mod.rs new file mode 100644 index 0000000..253faaf --- /dev/null +++ b/ferro-ta-main/src/volume/mod.rs @@ -0,0 +1,15 @@ +//! Volume indicators — require volume data to measure buying and selling pressure. +//! Each indicator lives in its own file for maintainability. + +mod ad; +mod adosc; +mod obv; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::ad::ad, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adosc::adosc, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::obv::obv, m)?)?; + Ok(()) +} diff --git a/ferro-ta-main/src/volume/obv.rs b/ferro-ta-main/src/volume/obv.rs new file mode 100644 index 0000000..480e87a --- /dev/null +++ b/ferro-ta-main/src/volume/obv.rs @@ -0,0 +1,18 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// On Balance Volume: cumulates volume * sign(close - prev_close). +#[pyfunction] +pub fn obv<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let closes = close.as_slice()?; + let vols = volume.as_slice()?; + let n = closes.len(); + validation::validate_equal_length(&[(n, "close"), (vols.len(), "volume")])?; + let result = ferro_ta_core::volume::obv(closes, vols); + Ok(result.into_pyarray(py)) +} diff --git a/ferro-ta-main/tests/conftest.py b/ferro-ta-main/tests/conftest.py new file mode 100644 index 0000000..2906a5d --- /dev/null +++ b/ferro-ta-main/tests/conftest.py @@ -0,0 +1,101 @@ +""" +Shared pytest fixtures for all test modules. + +This module provides session-scoped fixtures to avoid duplicated data setup +across multiple test files. All fixtures use seeded RNG for reproducibility. +""" + +from __future__ import annotations + +import pathlib + +import numpy as np +import pandas as pd +import pytest + + +@pytest.fixture(scope="session") +def ohlcv_500(): + """500-bar seeded OHLCV data, always the same across all test files. + + Returns a dictionary with keys: open, high, low, close, volume. + All arrays are numpy float64 arrays of length 500. + + Seeded with RNG seed=42 for reproducibility. + """ + rng = np.random.default_rng(42) + n = 500 + + # Generate realistic price movement + close = 100.0 + np.cumsum(rng.standard_normal(n) * 0.5) + 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 + volume = rng.uniform(500.0, 5000.0, n) + + return { + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": volume, + } + + +@pytest.fixture(scope="session") +def ohlcv_real(): + """Real data from tests/fixtures/ohlcv_daily.csv (252 bars). + + Returns a dictionary with keys: open, high, low, close, volume. + All arrays are numpy float64 arrays of length 252. + + This is real market data for integration testing. + """ + fixture_path = pathlib.Path(__file__).parent / "fixtures" / "ohlcv_daily.csv" + + if not fixture_path.exists(): + pytest.skip(f"Fixture file not found: {fixture_path}") + + df = pd.read_csv(fixture_path) + + # Ensure required columns exist + required_cols = ["open", "high", "low", "close", "volume"] + for col in required_cols: + if col not in df.columns: + pytest.skip(f"Required column '{col}' not found in fixture") + + return { + "open": df["open"].to_numpy(dtype=np.float64), + "high": df["high"].to_numpy(dtype=np.float64), + "low": df["low"].to_numpy(dtype=np.float64), + "close": df["close"].to_numpy(dtype=np.float64), + "volume": df["volume"].to_numpy(dtype=np.float64), + } + + +@pytest.fixture(scope="session") +def ohlcv_100(): + """100-bar seeded OHLCV data for quick tests. + + Returns a dictionary with keys: open, high, low, close, volume. + All arrays are numpy float64 arrays of length 100. + + Seeded with RNG seed=42 for reproducibility. + """ + rng = np.random.default_rng(42) + n = 100 + + # Generate realistic price movement + close = 44.0 + np.cumsum(rng.standard_normal(n) * 0.5) + high = close + rng.uniform(0.1, 1.0, n) + low = close - rng.uniform(0.1, 1.0, n) + open_ = close + rng.standard_normal(n) * 0.2 + volume = rng.uniform(500.0, 2000.0, n) + + return { + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": volume, + } diff --git a/ferro-ta-main/tests/fixtures/ohlcv_daily.csv b/ferro-ta-main/tests/fixtures/ohlcv_daily.csv new file mode 100644 index 0000000..6bc76de --- /dev/null +++ b/ferro-ta-main/tests/fixtures/ohlcv_daily.csv @@ -0,0 +1,253 @@ +bar,open,high,low,close,volume +1,100.0,101.0096,100.11,100.4871,628918 +2,100.4871,98.2117,97.514,97.5764,1546052 +3,97.5764,97.151,96.7285,97.1428,1250527 +4,97.1428,98.3378,97.7512,98.3053,1056197 +5,98.3053,99.4496,98.8416,99.0242,1831834 +6,99.0242,100.3838,100.2659,100.3587,595725 +7,100.3587,99.9711,99.287,99.3638,1516878 +8,99.3638,99.1319,98.6881,98.8687,1667575 +9,98.8687,99.7248,98.4449,99.5105,1049963 +10,99.5105,99.1776,98.4715,98.7757,1951264 +11,98.7757,100.5353,100.056,100.4781,995294 +12,100.4781,101.866,101.2132,101.4888,840364 +13,101.4888,100.6228,100.4475,100.5061,1847131 +14,100.5061,101.9639,101.5043,101.85,932492 +15,101.85,102.1313,101.6619,101.9838,1207350 +16,101.9838,101.7642,101.2011,101.5254,1557748 +17,101.5254,101.8928,100.699,101.1368,1481643 +18,101.1368,98.7793,98.5339,98.6142,1206644 +19,98.6142,99.8648,99.1162,99.5109,1404257 +20,99.5109,99.2747,98.7561,98.8506,546226 +21,98.8506,97.5383,96.5429,96.9888,1783506 +22,96.9888,97.5607,97.0174,97.2251,922075 +23,97.2251,97.7904,97.3347,97.4854,1126938 +24,97.4854,96.722,96.3625,96.5468,1721030 +25,96.5468,95.0748,94.6213,94.8439,1850932 +26,94.8439,95.7696,95.2384,95.5563,1251567 +27,95.5563,95.6458,95.4058,95.4438,1410794 +28,95.4438,94.0184,92.9349,93.4007,1042718 +29,93.4007,94.4143,93.8111,93.9888,1646344 +30,93.9888,93.8595,93.0782,93.5147,1953764 +31,93.5147,93.6976,93.0965,93.2546,691365 +32,93.2546,91.0637,90.7583,90.8663,1183664 +33,90.8663,90.7351,90.0513,90.0838,1239143 +34,90.0838,90.4351,89.701,90.4252,1579194 +35,90.4252,90.5889,90.0469,90.1277,1680329 +36,90.1277,92.3764,91.8281,91.9922,562421 +37,91.9922,94.598,93.7382,94.039,949989 +38,94.039,94.1611,93.2204,93.5174,1887680 +39,93.5174,93.9193,92.7603,93.2337,1200661 +40,93.2337,95.3766,93.058,94.4338,1674102 +41,94.4338,95.5194,94.0359,95.0491,1711396 +42,95.0491,94.1137,93.6312,93.9186,900463 +43,93.9186,94.2481,93.6748,93.7484,1314240 +44,93.7484,93.0932,92.0957,92.3202,710373 +45,92.3202,93.0891,92.2133,92.2734,981019 +46,92.2734,92.1528,91.168,91.61,661611 +47,91.61,91.6056,90.2687,90.6409,1190050 +48,90.6409,89.8571,89.2684,89.4405,1712377 +49,89.4405,89.2754,88.965,89.2572,771052 +50,89.2572,89.2182,88.1082,88.6748,746911 +51,88.6748,89.6331,88.8598,88.931,835126 +52,88.931,89.9223,89.2409,89.3389,1840169 +53,89.3389,89.3063,88.6247,88.8151,1604926 +54,88.8151,89.6312,88.4764,89.0858,1620184 +55,89.0858,92.1057,91.2367,91.3187,1620109 +56,91.3187,93.8646,92.9303,93.3479,768902 +57,93.3479,94.4627,94.2743,94.2767,1150548 +58,94.2767,95.4494,94.7394,94.7824,715846 +59,94.7824,96.7076,95.703,95.7263,1891253 +60,95.7263,94.4177,93.8479,94.0049,1541393 +61,94.0049,95.9568,95.31,95.3246,1150753 +62,95.3246,96.2843,95.8944,96.0517,685630 +63,96.0517,97.7877,96.8732,97.5253,1281534 +64,97.5253,96.6445,96.4194,96.5365,929669 +65,96.5365,97.3461,96.7773,96.8211,1623239 +66,96.8211,101.4495,100.0776,100.5064,1128671 +67,100.5064,100.8357,99.8076,100.1032,1770469 +68,100.1032,102.3156,101.6105,101.9439,526755 +69,101.9439,98.7712,98.1667,98.691,1899835 +70,98.691,102.2637,101.5465,101.9937,1801608 +71,101.9937,102.7755,101.1328,101.8801,1296524 +72,101.8801,100.4913,99.0253,99.9432,871759 +73,99.9432,104.9395,104.1362,104.3283,1036791 +74,104.3283,107.5932,106.9336,107.0649,719259 +75,107.0649,108.5809,108.1416,108.3454,1366123 +76,108.3454,106.2699,106.0021,106.1435,730420 +77,106.1435,106.8408,106.2938,106.545,1273993 +78,106.545,107.0132,106.7969,106.8253,1521071 +79,106.8253,107.243,106.6884,106.8575,1239625 +80,106.8575,112.5661,110.5238,111.4002,772247 +81,111.4002,112.5059,111.4549,112.0783,639981 +82,112.0783,112.8239,111.5719,112.5537,1750187 +83,112.5537,114.4126,113.5195,114.1532,981044 +84,114.1532,114.6691,114.4716,114.6391,1703038 +85,114.6391,114.6397,114.4082,114.4955,1950475 +86,114.4955,110.2522,110.0246,110.1218,1786358 +87,110.1218,110.8495,109.6719,110.6437,747681 +88,110.6437,114.4145,113.0533,113.5437,1125334 +89,113.5437,113.0529,112.4233,113.0183,853169 +90,113.0183,115.5919,114.2578,115.2561,945314 +91,115.2561,117.5838,116.3032,117.3263,1233879 +92,117.3263,118.9948,118.7791,118.8186,1478881 +93,118.8186,118.1932,116.9184,117.6113,1396167 +94,117.6113,117.9972,116.8698,117.3102,1542197 +95,117.3102,120.9896,119.6016,120.5491,1585124 +96,120.5491,118.773,117.5709,118.466,706111 +97,118.466,120.0525,119.0281,119.6344,635158 +98,119.6344,117.8814,117.3039,117.4872,880075 +99,117.4872,119.9224,118.1887,119.5584,1518914 +100,119.5584,119.9713,118.756,119.7235,1283947 +101,119.7235,116.3971,115.7113,116.0541,1678933 +102,116.0541,118.8769,118.156,118.6583,675655 +103,118.6583,118.1565,117.049,117.6777,1607346 +104,117.6777,118.2864,117.6168,118.1268,1847549 +105,118.1268,117.9907,117.6748,117.9008,629388 +106,117.9008,116.1367,115.2044,116.0641,1904191 +107,116.0641,115.2764,114.6883,114.8044,1419013 +108,114.8044,115.6217,114.3665,114.8585,1219054 +109,114.8585,116.6657,114.9893,116.3433,1816902 +110,116.3433,113.7872,112.6234,112.8818,1455610 +111,112.8818,113.8388,112.1922,113.0983,1146952 +112,113.0983,110.8541,110.173,110.4157,943038 +113,110.4157,111.4708,110.5307,111.317,1086389 +114,111.317,111.704,111.1852,111.4412,1995551 +115,111.4412,112.7259,112.2199,112.648,1521092 +116,112.648,113.9776,113.4754,113.5774,1845550 +117,113.5774,114.4142,113.7224,113.9468,933362 +118,113.9468,113.5933,113.0557,113.5266,1484403 +119,113.5266,110.8808,109.3502,110.1666,931699 +120,110.1666,109.4876,108.9235,109.0789,1774113 +121,109.0789,110.0312,108.7087,109.4352,1169825 +122,109.4352,108.9373,108.1551,108.205,560226 +123,108.205,107.1959,106.0871,106.8783,579538 +124,106.8783,106.7636,105.4215,106.3364,789562 +125,106.3364,104.3682,103.6217,104.1636,506568 +126,104.1636,104.942,103.5074,103.9901,563525 +127,103.9901,103.3527,102.5262,103.0775,1387944 +128,103.0775,103.8682,103.5995,103.7701,1187840 +129,103.7701,105.0537,104.1857,104.3645,1127552 +130,104.3645,106.2423,105.4754,106.0665,1367025 +131,106.0665,106.7783,106.5222,106.7083,1870573 +132,106.7083,110.103,109.9766,110.0648,1211253 +133,110.0648,112.3477,111.6883,111.8747,1101175 +134,111.8747,112.9035,111.5414,112.435,1451943 +135,112.435,109.5814,108.7227,108.9726,948006 +136,108.9726,112.7922,111.6323,112.1719,1127600 +137,112.1719,113.0201,112.6302,112.7906,1380725 +138,112.7906,114.0651,113.2662,113.8719,1478649 +139,113.8719,111.9591,110.5015,111.9556,1942656 +140,111.9556,114.4484,114.0638,114.396,1269108 +141,114.396,114.4303,113.8873,114.3329,868882 +142,114.3329,113.2826,112.2941,112.9068,971676 +143,112.9068,113.724,113.3037,113.5614,1231340 +144,113.5614,116.2439,114.9959,115.1891,759491 +145,115.1891,112.6195,111.0216,111.7839,1563763 +146,111.7839,111.442,108.9715,110.611,730419 +147,110.611,111.0706,109.5039,109.7425,1136705 +148,109.7425,111.4037,110.3767,110.541,966442 +149,110.541,110.7868,110.3012,110.7391,1634789 +150,110.7391,112.2675,111.3802,111.8825,1287632 +151,111.8825,115.2059,114.1409,114.6498,1702445 +152,114.6498,115.1578,114.1021,114.3551,1985482 +153,114.3551,116.9621,116.7344,116.8853,1219577 +154,116.8853,116.8333,116.0253,116.2861,1203990 +155,116.2861,118.0268,116.7367,117.11,791580 +156,117.11,120.5879,118.6928,119.1614,1235296 +157,119.1614,116.6627,116.0388,116.1827,1334259 +158,116.1827,115.8681,115.6583,115.776,1473723 +159,115.776,114.4624,113.6813,114.0593,1897091 +160,114.0593,115.2162,113.875,115.0456,1766718 +161,115.0456,115.1063,114.4969,114.9299,680249 +162,114.9299,113.3203,112.7791,112.8189,1775411 +163,112.8189,113.4475,112.9844,113.4185,512820 +164,113.4185,114.802,114.336,114.7105,1008283 +165,114.7105,115.7973,114.8509,115.6885,1349785 +166,115.6885,116.7006,116.0677,116.178,510971 +167,116.178,114.437,112.9682,113.6776,1977149 +168,113.6776,115.7091,113.6704,114.3314,1642148 +169,114.3314,114.4723,113.4552,113.8619,1260674 +170,113.8619,112.355,111.7547,111.8865,1598620 +171,111.8865,115.3245,114.4812,114.624,1732205 +172,114.624,112.5346,111.5815,111.8184,1600630 +173,111.8184,111.4297,110.8447,110.9788,987890 +174,110.9788,112.1931,111.1572,111.87,1966875 +175,111.87,111.2033,110.5506,110.8505,1166710 +176,110.8505,112.5696,111.3285,111.6288,1733874 +177,111.6288,110.0597,109.1479,109.9304,1485539 +178,109.9304,109.33,108.2456,108.6334,1660581 +179,108.6334,109.3945,108.2684,109.0597,850479 +180,109.0597,109.2917,108.8068,109.145,1787187 +181,109.145,110.2423,109.7227,109.949,1415919 +182,109.949,109.572,107.3938,108.2658,1507419 +183,108.2658,112.6662,111.3495,112.4384,1173457 +184,112.4384,112.0957,111.366,111.9894,1095410 +185,111.9894,113.2929,112.1522,112.9966,1137332 +186,112.9966,116.6098,116.3131,116.5097,1711147 +187,116.5097,116.6542,115.2901,116.2964,590435 +188,116.2964,116.7187,115.5495,115.6502,1088553 +189,115.6502,119.2327,118.2301,118.5783,1332214 +190,118.5783,117.1719,116.3583,117.0681,526503 +191,117.0681,117.5205,116.5244,116.5346,1254666 +192,116.5346,116.0185,114.9821,115.9712,1311284 +193,115.9712,113.4682,112.8995,113.1954,1975988 +194,113.1954,114.718,114.1531,114.562,1613476 +195,114.562,112.8029,112.1372,112.3335,1020523 +196,112.3335,113.2535,112.797,113.0161,1811471 +197,113.0161,113.2275,112.4145,112.767,1924901 +198,112.767,112.1626,111.6509,111.919,1992052 +199,111.919,114.1288,113.0203,113.2801,978423 +200,113.2801,113.9877,113.3059,113.8478,1933024 +201,113.8478,110.4914,109.6061,110.0366,576872 +202,110.0366,108.9754,108.37,108.479,869099 +203,108.479,110.4578,109.754,110.3226,1453345 +204,110.3226,112.6389,111.5816,112.0919,1076756 +205,112.0919,113.1899,112.6986,113.0647,727226 +206,113.0647,114.8801,114.2817,114.2886,502491 +207,114.2886,114.6545,113.0708,113.9617,1115201 +208,113.9617,113.9756,113.3414,113.3959,1146990 +209,113.3959,116.664,114.4855,115.3486,1794909 +210,115.3486,118.078,117.4591,117.9114,1524887 +211,117.9114,116.5792,115.6707,115.9302,787035 +212,115.9302,117.029,116.7566,116.9192,725681 +213,116.9192,117.6875,116.7586,117.2316,730334 +214,117.2316,113.8826,112.6421,113.315,665944 +215,113.315,112.6522,111.559,111.6142,921000 +216,111.6142,114.9533,114.4442,114.6573,1780054 +217,114.6573,113.1621,112.9863,113.1556,1335402 +218,113.1556,117.7083,116.0043,116.667,1181686 +219,116.667,115.5825,114.7224,115.0905,669450 +220,115.0905,117.655,116.8484,117.2824,1401100 +221,117.2824,118.6399,117.3818,118.4381,1075002 +222,118.4381,118.2373,116.4975,117.4855,528551 +223,117.4855,121.3871,120.4138,120.5677,721909 +224,120.5677,120.748,119.9662,120.6257,1390745 +225,120.6257,121.4168,120.5896,121.3802,897482 +226,121.3802,122.3614,120.7292,121.2714,506011 +227,121.2714,121.4795,121.3018,121.3286,1122569 +228,121.3286,119.4852,118.9497,119.3951,1304300 +229,119.3951,117.4912,116.9624,117.1917,1965063 +230,117.1917,117.5937,116.3915,117.0112,1754367 +231,117.0112,118.8699,118.3446,118.8468,777871 +232,118.8468,119.7234,118.7276,119.4968,1330311 +233,119.4968,121.5846,120.7484,121.5043,724676 +234,121.5043,122.0598,121.5843,121.9366,1128908 +235,121.9366,122.7443,122.0513,122.4358,1658325 +236,122.4358,121.8828,121.5679,121.7342,1834484 +237,121.7342,119.9781,118.8297,119.6901,1879863 +238,119.6901,120.9963,119.0242,120.2973,1788474 +239,120.2973,116.9908,116.9557,116.9738,1119501 +240,116.9738,120.7315,118.9034,119.1393,1275529 +241,119.1393,122.6532,121.7338,121.9654,1549235 +242,121.9654,120.5622,119.5067,120.3658,1798358 +243,120.3658,123.9271,122.5409,123.7193,1982033 +244,123.7193,127.0702,126.9277,127.0109,1065717 +245,127.0109,129.7757,127.2016,127.7453,1077013 +246,127.7453,129.875,129.323,129.6404,1113709 +247,129.6404,131.4666,131.2631,131.4462,1653376 +248,131.4462,132.4301,130.8694,132.1493,696663 +249,132.1493,134.1658,132.5474,133.5102,1209319 +250,133.5102,134.1821,133.1628,133.7184,1630256 +251,133.7184,131.3331,130.6832,131.0665,1182053 +252,131.0665,132.3175,131.1772,131.654,889159 diff --git a/ferro-ta-main/tests/integration/conftest.py b/ferro-ta-main/tests/integration/conftest.py new file mode 100644 index 0000000..d06a4a1 --- /dev/null +++ b/ferro-ta-main/tests/integration/conftest.py @@ -0,0 +1,7 @@ +""" +Integration test conftest — inherits shared fixtures from tests/conftest.py. + +pytest automatically loads parent conftest.py files, so all fixtures +defined in tests/conftest.py (ohlcv_500, ohlcv_100, ohlcv_real) are +available here without any explicit import. +""" diff --git a/ferro-ta-main/tests/integration/test_cross_surface_manifest.py b/ferro-ta-main/tests/integration/test_cross_surface_manifest.py new file mode 100644 index 0000000..653593d --- /dev/null +++ b/ferro-ta-main/tests/integration/test_cross_surface_manifest.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +if str(ROOT / "python") not in sys.path: + sys.path.insert(0, str(ROOT / "python")) +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from build_api_manifest import build_manifest + + +def test_api_manifest_is_deterministic_and_current() -> None: + manifest_path = ROOT / "docs" / "api_manifest.json" + assert manifest_path.exists(), "docs/api_manifest.json is missing" + + expected = build_manifest(ROOT, include_runtime_metadata=False) + actual = json.loads(manifest_path.read_text(encoding="utf-8")) + + assert actual == expected diff --git a/ferro-ta-main/tests/integration/test_integration.py b/ferro-ta-main/tests/integration/test_integration.py new file mode 100644 index 0000000..301d133 --- /dev/null +++ b/ferro-ta-main/tests/integration/test_integration.py @@ -0,0 +1,341 @@ +""" +Integration tests using the synthetic OHLCV fixture in tests/fixtures/. + +These tests verify that: +- All major indicator categories produce finite output on realistic data. +- Output lengths match the input length. +- Error codes and suggestion hints are included in exception messages. +- ferro_ta.indicators() and ferro_ta.info() work correctly. +- Logging utilities (enable_debug, log_call, benchmark) work correctly. +""" + +from __future__ import annotations + +import csv +import logging +from pathlib import Path + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Load the OHLCV fixture +# --------------------------------------------------------------------------- + +FIXTURE_PATH = Path(__file__).parent.parent / "fixtures" / "ohlcv_daily.csv" + + +def _load_fixture() -> tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray +]: + """Return (open, high, low, close, volume) as float64 arrays.""" + rows = [] + with open(FIXTURE_PATH, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + rows.append(row) + open_ = np.array([float(r["open"]) for r in rows]) + high = np.array([float(r["high"]) for r in rows]) + low = np.array([float(r["low"]) for r in rows]) + close = np.array([float(r["close"]) for r in rows]) + volume = np.array([float(r["volume"]) for r in rows]) + return open_, high, low, close, volume + + +@pytest.fixture(scope="module") +def ohlcv(): + return _load_fixture() + + +# --------------------------------------------------------------------------- +# Fixture sanity +# --------------------------------------------------------------------------- + + +def test_fixture_loads(ohlcv): + o, h, l, c, v = ohlcv + assert len(c) == 252 + assert np.all(h >= l) + assert np.all(v > 0) + + +# --------------------------------------------------------------------------- +# Overlap indicators on real OHLCV data +# --------------------------------------------------------------------------- + + +def test_sma_on_fixture(ohlcv): + from ferro_ta import SMA + + _, _, _, close, _ = ohlcv + result = SMA(close, timeperiod=20) + assert len(result) == len(close) + # First 19 values should be NaN, rest finite + assert np.all(np.isnan(result[:19])) + assert np.all(np.isfinite(result[19:])) + + +def test_ema_on_fixture(ohlcv): + from ferro_ta import EMA + + _, _, _, close, _ = ohlcv + result = EMA(close, timeperiod=14) + assert len(result) == len(close) + assert np.all(np.isfinite(result[13:])) + + +def test_bbands_on_fixture(ohlcv): + from ferro_ta import BBANDS + + _, _, _, close, _ = ohlcv + upper, mid, lower = BBANDS(close, timeperiod=20) + assert len(upper) == len(close) + assert np.all(upper[19:] >= mid[19:]) + assert np.all(mid[19:] >= lower[19:]) + + +# --------------------------------------------------------------------------- +# Momentum indicators +# --------------------------------------------------------------------------- + + +def test_rsi_on_fixture(ohlcv): + from ferro_ta import RSI + + _, _, _, close, _ = ohlcv + result = RSI(close, timeperiod=14) + assert len(result) == len(close) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + +def test_macd_on_fixture(ohlcv): + from ferro_ta import MACD + + _, _, _, close, _ = ohlcv + macd, signal, hist = MACD(close) + assert len(macd) == len(close) + + +def test_adx_on_fixture(ohlcv): + from ferro_ta import ADX + + _, high, low, close, _ = ohlcv + result = ADX(high, low, close, timeperiod=14) + assert len(result) == len(close) + + +def test_stoch_on_fixture(ohlcv): + from ferro_ta import STOCH + + _, high, low, close, _ = ohlcv + slowk, slowd = STOCH(high, low, close) + assert len(slowk) == len(close) + + +# --------------------------------------------------------------------------- +# Volatility indicators +# --------------------------------------------------------------------------- + + +def test_atr_on_fixture(ohlcv): + from ferro_ta import ATR + + _, high, low, close, _ = ohlcv + result = ATR(high, low, close, timeperiod=14) + assert len(result) == len(close) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + +# --------------------------------------------------------------------------- +# Volume indicators +# --------------------------------------------------------------------------- + + +def test_obv_on_fixture(ohlcv): + from ferro_ta import OBV + + _, _, _, close, volume = ohlcv + result = OBV(close, volume) + assert len(result) == len(close) + assert np.all(np.isfinite(result)) + + +# --------------------------------------------------------------------------- +# Error handling — error codes and suggestion hints +# --------------------------------------------------------------------------- + + +def test_value_error_has_code(): + from ferro_ta.core.exceptions import FerroTAValueError, check_timeperiod + + with pytest.raises(FerroTAValueError) as exc_info: + check_timeperiod(0, "timeperiod", minimum=1) + exc = exc_info.value + assert exc.code == "FTERR001" + assert "FTERR001" in str(exc) + assert exc.suggestion is not None + assert "Suggestion" in str(exc) + + +def test_input_error_length_mismatch_has_code(): + from ferro_ta.core.exceptions import FerroTAInputError, check_equal_length + + with pytest.raises(FerroTAInputError) as exc_info: + check_equal_length(open=np.array([1.0, 2.0]), close=np.array([1.0])) + exc = exc_info.value + assert exc.code == "FTERR004" + assert "Suggestion" in str(exc) + + +def test_input_error_too_short_has_code(): + from ferro_ta.core.exceptions import FerroTAInputError, check_min_length + + with pytest.raises(FerroTAInputError) as exc_info: + check_min_length(np.array([1.0]), 10, "close") + exc = exc_info.value + assert exc.code == "FTERR003" + assert "Suggestion" in str(exc) + + +def test_finite_check_error_has_code(): + from ferro_ta.core.exceptions import FerroTAInputError, check_finite + + arr = np.array([1.0, float("nan"), 3.0]) + with pytest.raises(FerroTAInputError) as exc_info: + check_finite(arr, "close") + exc = exc_info.value + assert exc.code == "FTERR005" + assert "Suggestion" in str(exc) + + +# --------------------------------------------------------------------------- +# API discovery +# --------------------------------------------------------------------------- + + +def test_indicators_returns_list(): + import ferro_ta + + result = ferro_ta.indicators() + assert isinstance(result, list) + assert len(result) > 20 + names = [d["name"] for d in result] + assert "SMA" in names + assert "RSI" in names + assert "ATR" in names + + +def test_methods_returns_public_callables(): + import ferro_ta + + result = ferro_ta.methods() + assert isinstance(result, list) + assert any(d["name"] == "SMA" and d["category"] == "top_level" for d in result) + assert any( + d["name"] == "option_price" and d["category"] == "options" for d in result + ) + + +def test_about_reports_version_and_counts(): + import ferro_ta + + meta = ferro_ta.about() + assert meta["version"] == ferro_ta.__version__ + assert meta["indicator_count"] > 20 + assert meta["method_count"] >= meta["indicator_count"] + assert "__version__" in meta["top_level_exports"] + + +def test_indicators_filter_by_category(): + import ferro_ta + + overlap = ferro_ta.indicators(category="overlap") + assert all(d["category"] == "overlap" for d in overlap) + assert any(d["name"] == "SMA" for d in overlap) + + +def test_info_by_function(): + import ferro_ta + + d = ferro_ta.info(ferro_ta.SMA) + assert d["name"] == "SMA" + assert "close" in d["params"] + assert "timeperiod" in d["params"] + assert isinstance(d["doc"], str) + + +def test_info_by_string(): + import ferro_ta + + d = ferro_ta.info("EMA") + assert d["name"] == "EMA" + + +def test_info_unknown_raises(): + import ferro_ta + + with pytest.raises(ValueError, match="No indicator named"): + ferro_ta.info("DOES_NOT_EXIST") + + +# --------------------------------------------------------------------------- +# Logging utilities +# --------------------------------------------------------------------------- + + +def test_get_logger_returns_logger(): + import ferro_ta + + logger = ferro_ta.get_logger() + assert isinstance(logger, logging.Logger) + assert logger.name == "ferro_ta" + + +def test_enable_disable_debug(): + import ferro_ta + + ferro_ta.enable_debug() + assert ferro_ta.get_logger().level == logging.DEBUG + ferro_ta.disable_debug() + assert ferro_ta.get_logger().level == logging.WARNING + + +def test_debug_mode_context_manager(): + import ferro_ta + + with ferro_ta.debug_mode() as logger: + assert logger.level == logging.DEBUG + # After context, should be restored + assert ferro_ta.get_logger().level == logging.WARNING + + +def test_log_call_returns_result(ohlcv): + import ferro_ta + from ferro_ta import SMA + + _, _, _, close, _ = ohlcv + result = ferro_ta.log_call(SMA, close, timeperiod=10) + assert len(result) == len(close) + + +def test_benchmark_returns_stats(ohlcv): + import ferro_ta + from ferro_ta import SMA + + _, _, _, close, _ = ohlcv + stats = ferro_ta.benchmark(SMA, close, timeperiod=10, n=5, warmup=1) + assert "mean_ms" in stats + assert stats["mean_ms"] > 0 + assert stats["n"] == 5 + + +def test_traced_decorator(): + import ferro_ta + + @ferro_ta.traced + def dummy(x): + return x * 2 + + assert dummy(21) == 42 diff --git a/ferro-ta-main/tests/integration/test_streaming_accuracy.py b/ferro-ta-main/tests/integration/test_streaming_accuracy.py new file mode 100644 index 0000000..ea3f67e --- /dev/null +++ b/ferro-ta-main/tests/integration/test_streaming_accuracy.py @@ -0,0 +1,540 @@ +""" +Streaming accuracy tests: bar-by-bar == batch (Priority 3 - no optional deps). + +Core claim: "bar-by-bar streaming == batch." Any divergence is a genuine bug. + +This module validates that streaming (incremental) and batch (vectorized) modes +produce identical results within strict tolerances. + +Pattern for each test: +1. Compute batch: batch_out = ferro_ta.INDICATOR(...) +2. Feed bar-by-bar: streamer = StreamingINDICATOR(...); [streamer.update(...) for bar in data] +3. Assert: np.allclose(stream_arr, batch_arr, equal_nan=True, atol=1e-12) + +All tests use NO optional dependencies - they run in every CI environment. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ferro_ta +from ferro_ta.data.streaming import ( + StreamingATR, + StreamingBBands, + StreamingEMA, + StreamingMACD, + StreamingRSI, + StreamingSMA, + StreamingStoch, + StreamingSupertrend, + StreamingVWAP, +) + +# --------------------------------------------------------------------------- +# Test Data (seeded for reproducibility) +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +N = 200 + +CLOSE = 44.0 + np.cumsum(RNG.standard_normal(N) * 0.5) +HIGH = CLOSE + RNG.uniform(0.1, 1.0, N) +LOW = CLOSE - RNG.uniform(0.1, 1.0, N) +OPEN = CLOSE + RNG.standard_normal(N) * 0.2 +VOLUME = RNG.uniform(500.0, 2000.0, N) + + +# --------------------------------------------------------------------------- +# StreamingSMA Tests +# --------------------------------------------------------------------------- + + +class TestStreamingSMA: + """StreamingSMA vs ferro_ta.SMA — atol=1e-12 (identical arithmetic).""" + + @pytest.mark.parametrize("period", [5, 10, 20, 50]) + def test_streaming_matches_batch(self, period): + """Streaming SMA should match batch SMA exactly.""" + # Batch + batch_out = ferro_ta.SMA(CLOSE, timeperiod=period) + + # Streaming + streamer = StreamingSMA(period=period) + stream_out = np.array([streamer.update(c) for c in CLOSE]) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-12) + + def test_warmup_produces_nan(self): + """First period-1 updates should return NaN.""" + period = 10 + streamer = StreamingSMA(period=period) + + for i in range(period - 1): + val = streamer.update(CLOSE[i]) + assert np.isnan(val), f"Expected NaN at index {i}, got {val}" + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 10 + streamer = StreamingSMA(period=period) + + # First pass + first_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + # Reset and second pass + streamer.reset() + second_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-14) + + +# --------------------------------------------------------------------------- +# StreamingEMA Tests +# --------------------------------------------------------------------------- + + +class TestStreamingEMA: + """StreamingEMA vs ferro_ta.EMA — atol=1e-12 (same recursive formula, same seed).""" + + @pytest.mark.parametrize("period", [5, 10, 20, 50]) + def test_streaming_matches_batch(self, period): + """Streaming EMA should match batch EMA exactly.""" + # Batch + batch_out = ferro_ta.EMA(CLOSE, timeperiod=period) + + # Streaming + streamer = StreamingEMA(period=period) + stream_out = np.array([streamer.update(c) for c in CLOSE]) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-12) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 10 + streamer = StreamingEMA(period=period) + + # First pass + first_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + # Reset and second pass + streamer.reset() + second_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-14) + + +# --------------------------------------------------------------------------- +# StreamingRSI Tests +# --------------------------------------------------------------------------- + + +class TestStreamingRSI: + """StreamingRSI vs ferro_ta.RSI — atol=1e-10; also verify range [0, 100].""" + + @pytest.mark.parametrize("period", [7, 14, 21]) + def test_streaming_matches_batch(self, period): + """Streaming RSI should match batch RSI.""" + # Batch + batch_out = ferro_ta.RSI(CLOSE, timeperiod=period) + + # Streaming + streamer = StreamingRSI(period=period) + stream_out = np.array([streamer.update(c) for c in CLOSE]) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10) + + def test_rsi_range_zero_to_hundred(self): + """RSI values should be in range [0, 100].""" + period = 14 + streamer = StreamingRSI(period=period) + stream_out = np.array([streamer.update(c) for c in CLOSE]) + + # Filter out NaN values + valid = stream_out[~np.isnan(stream_out)] + + assert np.all(valid >= 0.0), "RSI should be >= 0" + assert np.all(valid <= 100.0), "RSI should be <= 100" + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 14 + streamer = StreamingRSI(period=period) + + # First pass + first_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + # Reset and second pass + streamer.reset() + second_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-12) + + +# --------------------------------------------------------------------------- +# StreamingATR Tests +# --------------------------------------------------------------------------- + + +class TestStreamingATR: + """StreamingATR vs ferro_ta.ATR — atol=1e-10; verify positive values.""" + + @pytest.mark.parametrize("period", [7, 14, 21]) + def test_streaming_matches_batch(self, period): + """Streaming ATR should match batch ATR in the converged (post-warmup) region. + + Note: streaming ATR uses a different initialization seed than batch ATR, so + values may differ during the early warmup bars. The tail (last 30%) converges + to identical values. We compare the full overlap region with atol=0.05 to + capture any remaining seeding difference without false-positives. + """ + # Batch + batch_out = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=period) + + # Streaming + streamer = StreamingATR(period=period) + stream_out = np.array( + [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + ) + + # Compare only the overlap region where both arrays are valid + mask = np.isfinite(batch_out) & np.isfinite(stream_out) + assert np.allclose(stream_out[mask], batch_out[mask], atol=0.05) + """ATR values should be non-negative.""" + period = 14 + streamer = StreamingATR(period=period) + stream_out = np.array( + [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + ) + + # Filter out NaN values + valid = stream_out[~np.isnan(stream_out)] + + assert np.all(valid >= 0.0), "ATR should be non-negative" + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 14 + streamer = StreamingATR(period=period) + + # First pass + first_pass = np.array( + [ + streamer.update(h, l, c) + for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + ) + + # Reset and second pass + streamer.reset() + second_pass = np.array( + [ + streamer.update(h, l, c) + for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + ) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-12) + + +# --------------------------------------------------------------------------- +# StreamingBBands Tests +# --------------------------------------------------------------------------- + + +class TestStreamingBBands: + """StreamingBBands vs ferro_ta.BBANDS — atol=1e-10 for all 3 bands.""" + + @pytest.mark.parametrize("period", [10, 20, 30]) + def test_streaming_matches_batch(self, period): + """Streaming BBands middle band matches batch exactly; bands within expected range. + + Note: the streaming BBands Rust implementation uses sample std (ddof=1) while + the batch BBANDS (TA-Lib convention) uses population std (ddof=0). The middle + band (SMA) is identical. Upper/lower differ by a ~sqrt(N/(N-1)) factor; we + verify proximity with atol=0.2 and confirm internal consistency separately. + """ + # Batch + batch_upper, batch_middle, batch_lower = ferro_ta.BBANDS( + CLOSE, timeperiod=period + ) + + # Streaming + streamer = StreamingBBands(period=period, nbdevup=2.0, nbdevdn=2.0) + stream_results = [streamer.update(c) for c in CLOSE] + stream_upper = np.array([r[0] for r in stream_results]) + stream_middle = np.array([r[1] for r in stream_results]) + stream_lower = np.array([r[2] for r in stream_results]) + + # Compare only overlapping valid region + mask = np.isfinite(batch_middle) + # Middle band (SMA) must match exactly + assert np.allclose(stream_middle[mask], batch_middle[mask], atol=1e-10), ( + "BBands middle (SMA) must match batch exactly" + ) + # Upper/lower: streaming uses sample std; batch uses population std — use atol=0.2 + assert np.allclose(stream_upper[mask], batch_upper[mask], atol=0.2) + assert np.allclose(stream_lower[mask], batch_lower[mask], atol=0.2) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 20 + streamer = StreamingBBands(period=period, nbdevup=2.0, nbdevdn=2.0) + + # First pass + first_pass = [streamer.update(c) for c in CLOSE[:50]] + + # Reset and second pass + streamer.reset() + second_pass = [streamer.update(c) for c in CLOSE[:50]] + + # Compare all three bands + for i in range(len(first_pass)): + assert np.allclose( + first_pass[i], second_pass[i], equal_nan=True, atol=1e-14 + ) + + +# --------------------------------------------------------------------------- +# StreamingMACD Tests +# --------------------------------------------------------------------------- + + +class TestStreamingMACD: + """StreamingMACD vs ferro_ta.MACD — atol=1e-10; also verify histogram identity.""" + + def test_streaming_matches_batch(self): + """Streaming MACD should match batch MACD.""" + # Batch + batch_macd, batch_signal, batch_hist = ferro_ta.MACD( + CLOSE, fastperiod=12, slowperiod=26, signalperiod=9 + ) + + # Streaming + streamer = StreamingMACD(fastperiod=12, slowperiod=26, signalperiod=9) + stream_results = [streamer.update(c) for c in CLOSE] + stream_macd = np.array([r[0] for r in stream_results]) + stream_signal = np.array([r[1] for r in stream_results]) + stream_hist = np.array([r[2] for r in stream_results]) + + # Streaming MACD starts computing sooner (fewer NaN warmup bars due to EMA seeding). + # Values where batch is valid are identical to batch values within floating-point. + mask = np.isfinite(batch_macd) + assert np.allclose(stream_macd[mask], batch_macd[mask], atol=1e-8) + assert np.allclose(stream_signal[mask], batch_signal[mask], atol=1e-8) + assert np.allclose(stream_hist[mask], batch_hist[mask], atol=1e-8) + + def test_histogram_identity(self): + """histogram should always equal macd - signal.""" + streamer = StreamingMACD(fastperiod=12, slowperiod=26, signalperiod=9) + stream_results = [streamer.update(c) for c in CLOSE] + stream_macd = np.array([r[0] for r in stream_results]) + stream_signal = np.array([r[1] for r in stream_results]) + stream_hist = np.array([r[2] for r in stream_results]) + + expected_hist = stream_macd - stream_signal + assert np.allclose(stream_hist, expected_hist, equal_nan=True, atol=1e-10) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + streamer = StreamingMACD(fastperiod=12, slowperiod=26, signalperiod=9) + + # First pass + first_pass = [streamer.update(c) for c in CLOSE[:50]] + + # Reset and second pass + streamer.reset() + second_pass = [streamer.update(c) for c in CLOSE[:50]] + + # Compare all three outputs + for i in range(len(first_pass)): + assert np.allclose( + first_pass[i], second_pass[i], equal_nan=True, atol=1e-14 + ) + + +# --------------------------------------------------------------------------- +# StreamingStoch Tests +# --------------------------------------------------------------------------- + + +class TestStreamingStoch: + """StreamingStoch vs ferro_ta.STOCH — atol=1e-10; verify [0, 100] range.""" + + def test_streaming_matches_batch(self): + """Streaming Stochastic should match batch Stochastic.""" + # Batch + batch_slowk, batch_slowd = ferro_ta.STOCH( + HIGH, LOW, CLOSE, fastk_period=5, slowk_period=3, slowd_period=3 + ) + + # Streaming + streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3) + stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + stream_slowk = np.array([r[0] for r in stream_results]) + stream_slowd = np.array([r[1] for r in stream_results]) + + # Streaming Stoch starts computing sooner (fewer NaN warmup bars). + # Values where batch is valid match exactly. + mask = np.isfinite(batch_slowk) + assert np.allclose(stream_slowk[mask], batch_slowk[mask], atol=1e-8) + assert np.allclose(stream_slowd[mask], batch_slowd[mask], atol=1e-8) + + def test_stoch_range_zero_to_hundred(self): + """Stochastic values should be in range [0, 100].""" + streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3) + stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + stream_slowk = np.array([r[0] for r in stream_results]) + stream_slowd = np.array([r[1] for r in stream_results]) + + # Filter out NaN values + valid_k = stream_slowk[~np.isnan(stream_slowk)] + valid_d = stream_slowd[~np.isnan(stream_slowd)] + + assert np.all(valid_k >= 0.0), "slowk should be >= 0" + assert np.all(valid_k <= 100.0), "slowk should be <= 100" + assert np.all(valid_d >= 0.0), "slowd should be >= 0" + assert np.all(valid_d <= 100.0), "slowd should be <= 100" + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3) + + # First pass + first_pass = [ + streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + + # Reset and second pass + streamer.reset() + second_pass = [ + streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + + # Compare + for i in range(len(first_pass)): + assert np.allclose( + first_pass[i], second_pass[i], equal_nan=True, atol=1e-14 + ) + + +# --------------------------------------------------------------------------- +# StreamingVWAP Tests +# --------------------------------------------------------------------------- + + +class TestStreamingVWAP: + """StreamingVWAP vs ferro_ta.VWAP — atol=1e-10.""" + + def test_streaming_matches_batch_cumulative(self): + """Streaming VWAP (cumulative) should match batch VWAP.""" + # Batch (cumulative: timeperiod=0) + batch_out = ferro_ta.VWAP(HIGH, LOW, CLOSE, VOLUME, timeperiod=0) + + # Streaming (cumulative) + streamer = StreamingVWAP() + stream_out = np.array( + [ + streamer.update(h, l, c, v) + for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME) + ] + ) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10) + + def test_streaming_matches_batch_rolling(self): + """Streaming VWAP (cumulative) matches batch cumulative VWAP.""" + # StreamingVWAP is cumulative only; compare against batch cumulative + batch_out = ferro_ta.VWAP(HIGH, LOW, CLOSE, VOLUME, timeperiod=0) + + # Streaming (cumulative) + streamer = StreamingVWAP() + stream_out = np.array( + [ + streamer.update(h, l, c, v) + for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME) + ] + ) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + streamer = StreamingVWAP() + + # First pass + first_pass = np.array( + [ + streamer.update(h, l, c, v) + for h, l, c, v in zip(HIGH[:50], LOW[:50], CLOSE[:50], VOLUME[:50]) + ] + ) + + # Reset and second pass + streamer.reset() + second_pass = np.array( + [ + streamer.update(h, l, c, v) + for h, l, c, v in zip(HIGH[:50], LOW[:50], CLOSE[:50], VOLUME[:50]) + ] + ) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-14) + + +# --------------------------------------------------------------------------- +# StreamingSupertrend Tests +# --------------------------------------------------------------------------- + + +class TestStreamingSupertrend: + """StreamingSupertrend vs ferro_ta.SUPERTREND — atol=1e-10.""" + + def test_streaming_matches_batch(self): + """Streaming SUPERTREND should match batch SUPERTREND.""" + period = 7 + multiplier = 3.0 + + # Batch + batch_line, batch_dir = ferro_ta.SUPERTREND( + HIGH, LOW, CLOSE, timeperiod=period, multiplier=multiplier + ) + + # Streaming + streamer = StreamingSupertrend(period=period, multiplier=multiplier) + stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + stream_line = np.array([r[0] for r in stream_results]) + stream_dir = np.array([r[1] for r in stream_results]) + + # Compare + assert np.allclose(stream_line, batch_line, equal_nan=True, atol=1e-10) + assert np.allclose(stream_dir, batch_dir, equal_nan=True, atol=1e-10) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 7 + multiplier = 3.0 + streamer = StreamingSupertrend(period=period, multiplier=multiplier) + + # First pass + first_pass = [ + streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + + # Reset and second pass + streamer.reset() + second_pass = [ + streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + + # Compare + for i in range(len(first_pass)): + assert np.allclose( + first_pass[i], second_pass[i], equal_nan=True, atol=1e-14 + ) diff --git a/ferro-ta-main/tests/integration/test_vs_pandas_ta.py b/ferro-ta-main/tests/integration/test_vs_pandas_ta.py new file mode 100644 index 0000000..0b1f66a --- /dev/null +++ b/ferro-ta-main/tests/integration/test_vs_pandas_ta.py @@ -0,0 +1,711 @@ +""" +Comparison tests: ferro_ta vs pandas-ta (Priority 4 - requires pandas-ta). + +This module validates ferro_ta against pandas-ta for indicators, using 500-bar data +for proper convergence of EMA-seeded indicators. Documents known formula differences +and expected tolerances. + +Requirements +------------ +Install pandas-ta before running these tests:: + + pip install pandas-ta + +The tests are automatically skipped when pandas-ta is not installed. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Skip the whole module when pandas-ta is not available +# --------------------------------------------------------------------------- + +pandas_ta = pytest.importorskip( + "pandas_ta", reason="pandas-ta not installed; skipping comparison tests" +) +pd = pytest.importorskip("pandas", reason="pandas required for pandas-ta") + +import ferro_ta # noqa: E402 + +# --------------------------------------------------------------------------- +# Shared test data from conftest.py +# --------------------------------------------------------------------------- + +# Use shared 500-bar fixture from conftest.py + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _nan_count(arr: np.ndarray) -> int: + """Return count of NaN values.""" + return int(np.sum(np.isnan(arr))) + + +def _valid_mask(*arrays: np.ndarray) -> np.ndarray: + """Return boolean mask for positions where *all* arrays are finite.""" + mask = np.ones(len(arrays[0]), dtype=bool) + for a in arrays: + mask &= ~np.isnan(a) + return mask + + +def _allclose( + a: np.ndarray, b: np.ndarray, atol: float = 1e-6, tail_fraction: float = 1.0 +) -> bool: + """Compare arrays within tolerance, optionally only comparing tail. + + Parameters + ---------- + a, b : np.ndarray + Arrays to compare + atol : float + Absolute tolerance + tail_fraction : float + Fraction of tail to compare (1.0 = all, 0.3 = last 30%) + + Returns + ------- + bool + True if arrays match within tolerance + """ + mask = _valid_mask(a, b) + if not mask.any(): + return False + + if tail_fraction < 1.0: + # Only compare last tail_fraction of data + n = len(a) + start_idx = int(n * (1 - tail_fraction)) + mask[:start_idx] = False + + if not mask.any(): + return False + + return bool(np.allclose(a[mask], b[mask], atol=atol)) + + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- + + +class TestSMAVsPandasTA: + """SMA — Exact match (deterministic).""" + + def test_sma_exact_match(self, ohlcv_500): + """SMA should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.SMA(close, timeperiod=period) + pt = pandas_ta.sma(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestEMAVsPandasTA: + """EMA — Tail 30% match (seed difference). + + ferro_ta starts EMA from bar 0, pandas-ta may use SMA seed. + After 350+ bars of decay, values should converge. + """ + + def test_ema_tail_convergence(self, ohlcv_500): + """EMA should converge in tail 30% of data.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.EMA(close, timeperiod=period) + pt = pandas_ta.ema(pd.Series(close), length=period).to_numpy() + + # Compare only last 30% + assert _allclose(ft, pt, atol=1e-4, tail_fraction=0.3) + + def test_ema_shorter_period_tighter(self, ohlcv_500): + """Shorter period EMA should have tighter convergence.""" + close = ohlcv_500["close"] + period = 10 + + ft = ferro_ta.EMA(close, timeperiod=period) + pt = pandas_ta.ema(pd.Series(close), length=period).to_numpy() + + # Shorter period converges faster + assert _allclose(ft, pt, atol=1e-5, tail_fraction=0.3) + + +class TestWMAVsPandasTA: + """WMA — Exact match (deterministic).""" + + def test_wma_exact_match(self, ohlcv_500): + """WMA should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.WMA(close, timeperiod=period) + pt = pandas_ta.wma(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestBBANDSVsPandasTA: + """BBANDS — Approximate match (ferro_ta uses population std; pandas-ta uses sample std).""" + + def test_bbands_approximate_match(self, ohlcv_500): + """BBANDS middle band matches exactly; upper/lower match within std-formula tolerance. + + ferro_ta follows TA-Lib convention: std = population std (ddof=0). + pandas-ta uses sample std (ddof=1). Middle band (SMA) is identical. + Upper/lower differ by a sqrt(N/(N-1)) factor (~0.5% for N=20), capped at atol=0.1. + """ + close = ohlcv_500["close"] + period = 20 + + ft_upper, ft_middle, ft_lower = ferro_ta.BBANDS( + close, timeperiod=period, nbdevup=2.0, nbdevdn=2.0 + ) + + # pandas-ta >= 0.3 returns columns named BBL_{period}_{std}_{std} + pt_bbands = pandas_ta.bbands(pd.Series(close), length=period, std=2.0) + # Locate columns robustly (column names vary across pandas-ta versions) + lower_col = next(c for c in pt_bbands.columns if c.startswith("BBL_")) + middle_col = next(c for c in pt_bbands.columns if c.startswith("BBM_")) + upper_col = next(c for c in pt_bbands.columns if c.startswith("BBU_")) + pt_lower = pt_bbands[lower_col].to_numpy() + pt_middle = pt_bbands[middle_col].to_numpy() + pt_upper = pt_bbands[upper_col].to_numpy() + + # Middle band (SMA) must be identical + assert _allclose(ft_middle, pt_middle, atol=1e-8), ( + "BBands middle (SMA) must match" + ) + # Upper/lower: differ due to ddof=0 vs ddof=1 + assert _allclose(ft_upper, pt_upper, atol=0.1) + assert _allclose(ft_lower, pt_lower, atol=0.1) + + +class TestTRIMAVsPandasTA: + """TRIMA — Approximate match (implementations differ slightly in boundary handling).""" + + def test_trima_approximate_match(self, ohlcv_500): + """TRIMA should be close to pandas-ta (both are SMA-of-SMA but boundary handling differs). + + Note: ferro_ta follows TA-Lib's TRIMA formula while pandas-ta uses a slightly + different implementation. Observed max difference is ~0.4 price units on + typical equity prices (~100), which is < 0.5%. We verify tail convergence + with atol=0.5 and confirm correct NaN warm-up length. + """ + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.TRIMA(close, timeperiod=period) + pt = pandas_ta.trima(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=0.5, tail_fraction=0.5) + + +class TestMACDVsPandasTA: + """MACD — Tail 30% match (EMA seed difference).""" + + def test_macd_tail_convergence(self, ohlcv_500): + """MACD should converge in tail 30% of data.""" + close = ohlcv_500["close"] + + ft_macd, ft_signal, ft_hist = ferro_ta.MACD( + close, fastperiod=12, slowperiod=26, signalperiod=9 + ) + + # pandas-ta returns DataFrame + pt_macd = pandas_ta.macd(pd.Series(close), fast=12, slow=26, signal=9) + pt_macd_line = pt_macd["MACD_12_26_9"].to_numpy() + pt_signal_line = pt_macd["MACDs_12_26_9"].to_numpy() + pt_hist = pt_macd["MACDh_12_26_9"].to_numpy() + + # Compare tail 30% + assert _allclose(ft_macd, pt_macd_line, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_signal, pt_signal_line, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_hist, pt_hist, atol=1e-2, tail_fraction=0.3) + + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- + + +class TestRSIVsPandasTA: + """RSI — Tail 30% match (Wilder seed difference).""" + + def test_rsi_tail_convergence(self, ohlcv_500): + """RSI should converge in tail 30% of data.""" + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.RSI(close, timeperiod=period) + pt = pandas_ta.rsi(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-3, tail_fraction=0.3) + + +class TestSTOCHVsPandasTA: + """STOCH — Tail 30% match.""" + + def test_stoch_tail_convergence(self, ohlcv_500): + """Stochastic should converge in tail 30% of data.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + + ft_slowk, ft_slowd = ferro_ta.STOCH( + high, low, close, fastk_period=14, slowk_period=3, slowd_period=3 + ) + + # pandas-ta returns DataFrame + pt_stoch = pandas_ta.stoch( + pd.Series(high), pd.Series(low), pd.Series(close), k=14, d=3, smooth_k=3 + ) + pt_slowk = pt_stoch["STOCHk_14_3_3"].to_numpy() + pt_slowd = pt_stoch["STOCHd_14_3_3"].to_numpy() + + assert _allclose(ft_slowk, pt_slowk, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_slowd, pt_slowd, atol=1e-2, tail_fraction=0.3) + + +class TestCCIVsPandasTA: + """CCI — Exact match (deterministic rolling formula).""" + + def test_cci_exact_match(self, ohlcv_500): + """CCI should match manually-computed reference (pandas-ta CCI has a formula bug).""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.CCI(high, low, close, timeperiod=period) + + # Compute CCI manually: (TP - SMA(TP)) / (0.015 * MeanAbsDev(TP)) + tp = (pd.Series(high) + pd.Series(low) + pd.Series(close)) / 3.0 + mean_tp = tp.rolling(period).mean() + mad_tp = tp.rolling(period).apply( + lambda x: np.mean(np.abs(x - x.mean())), raw=True + ) + pt = ((tp - mean_tp) / (0.015 * mad_tp)).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestWILLRVsPandasTA: + """WILLR — Exact match (deterministic).""" + + def test_willr_exact_match(self, ohlcv_500): + """Williams %R should match pandas-ta exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.WILLR(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt = df.ta.willr(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestMOMVsPandasTA: + """MOM — Exact match.""" + + def test_mom_exact_match(self, ohlcv_500): + """MOM should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 10 + + ft = ferro_ta.MOM(close, timeperiod=period) + pt = pandas_ta.mom(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestROCVsPandasTA: + """ROC — Exact match.""" + + def test_roc_exact_match(self, ohlcv_500): + """ROC should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 10 + + ft = ferro_ta.ROC(close, timeperiod=period) + pt = pandas_ta.roc(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestMFIVsPandasTA: + """MFI — Exact match.""" + + def test_mfi_exact_match(self, ohlcv_500): + """MFI should match pandas-ta exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + period = 14 + + ft = ferro_ta.MFI(high, low, close, volume, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close, "volume": volume}) + pt = df.ta.mfi(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestAROONVsPandasTA: + """AROON — Exact match.""" + + def test_aroon_exact_match(self, ohlcv_500): + """AROON should match pandas-ta exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + period = 14 + + ft_down, ft_up = ferro_ta.AROON(high, low, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low}) + pt_aroon = df.ta.aroon(length=period) + pt_down = pt_aroon[f"AROOND_{period}"].to_numpy() + pt_up = pt_aroon[f"AROONU_{period}"].to_numpy() + + assert _allclose(ft_down, pt_down, atol=1e-8) + assert _allclose(ft_up, pt_up, atol=1e-8) + + +# --------------------------------------------------------------------------- +# Volume/Volatility +# --------------------------------------------------------------------------- + + +class TestOBVVsPandasTA: + """OBV — Incremental match (offset constant, verify diffs).""" + + def test_obv_incremental_match(self, ohlcv_500): + """OBV differences should match (absolute values may have offset).""" + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + + ft = ferro_ta.OBV(close, volume) + + df = pd.DataFrame({"close": close, "volume": volume}) + pt = df.ta.obv().to_numpy() + + # OBV can have different starting values, compare differences + ft_diff = np.diff(ft) + pt_diff = np.diff(pt) + + # Remove NaN values from comparison + mask = ~np.isnan(ft_diff) & ~np.isnan(pt_diff) + assert np.allclose(ft_diff[mask], pt_diff[mask], atol=1e-8) + + +class TestATRVsPandasTA: + """ATR — Tail 30% match (Wilder seed difference).""" + + def test_atr_tail_convergence(self, ohlcv_500): + """ATR should converge in tail 30% of data.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.ATR(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt = df.ta.atr(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-2, tail_fraction=0.3) + + +class TestADXVsPandasTA: + """ADX — Tail 30% match (two levels of Wilder smoothing).""" + + def test_adx_tail_convergence(self, ohlcv_500): + """ADX should converge in tail 30% of data.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.ADX(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt = df.ta.adx(length=period)[f"ADX_{period}"].to_numpy() + + assert _allclose(ft, pt, atol=5e-2, tail_fraction=0.3) + + +# --------------------------------------------------------------------------- +# Extended Indicators (no prior validation) +# --------------------------------------------------------------------------- + + +class TestVWAPVsPandasTA: + """VWAP — Validate rolling VWAP against a reference numpy implementation.""" + + def test_vwap_rolling_match(self, ohlcv_500): + """Rolling VWAP should match a reference implementation using numpy.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + period = 20 + + ft = ferro_ta.VWAP(high, low, close, volume, timeperiod=period) + + # Reference: rolling VWAP = sum(typical_price * volume, N) / sum(volume, N) + tp = (np.array(high) + np.array(low) + np.array(close)) / 3.0 + vol = np.array(volume) + n = len(tp) + ref = np.full(n, np.nan) + for i in range(period - 1, n): + w = tp[i - period + 1 : i + 1] + v = vol[i - period + 1 : i + 1] + ref[i] = np.dot(w, v) / v.sum() + + assert _allclose(ft, ref, atol=1e-8) + + +class TestDONCHIANVsPandasTA: + """DONCHIAN — Exact match (rolling max(H), min(L), mean).""" + + def test_donchian_exact_match(self, ohlcv_500): + """Donchian Channels should match pandas-ta exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + period = 20 + + ft_upper, ft_middle, ft_lower = ferro_ta.DONCHIAN(high, low, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": ohlcv_500["close"]}) + pt_donchian = df.ta.donchian(lower_length=period, upper_length=period) + pt_lower = pt_donchian[f"DCL_{period}_{period}"].to_numpy() + pt_middle = pt_donchian[f"DCM_{period}_{period}"].to_numpy() + pt_upper = pt_donchian[f"DCU_{period}_{period}"].to_numpy() + + assert _allclose(ft_upper, pt_upper, atol=1e-8) + assert _allclose(ft_middle, pt_middle, atol=1e-8) + assert _allclose(ft_lower, pt_lower, atol=1e-8) + + +class TestHULL_MAVsPandasTA: + """HULL_MA — Exact match (WMA composition: deterministic).""" + + def test_hull_ma_exact_match(self, ohlcv_500): + """Hull MA should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 16 + + ft = ferro_ta.HULL_MA(close, timeperiod=period) + pt = pandas_ta.hma(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestICHIMOKUVsPandasTA: + """ICHIMOKU — Exact match for tenkan/kijun (rolling midpoint formula).""" + + def test_ichimoku_tenkan_kijun_match(self, ohlcv_500): + """Ichimoku tenkan and kijun should match pandas-ta.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + + ft_tenkan, ft_kijun, ft_senkou_a, ft_senkou_b, ft_chikou = ferro_ta.ICHIMOKU( + high, + low, + close, + tenkan_period=9, + kijun_period=26, + senkou_b_period=52, + displacement=26, + ) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt_ichimoku = df.ta.ichimoku(tenkan=9, kijun=26, senkou=52)[0] + pt_tenkan = pt_ichimoku["ITS_9"].to_numpy() + pt_kijun = pt_ichimoku["IKS_26"].to_numpy() + + assert _allclose(ft_tenkan, pt_tenkan, atol=1e-8) + assert _allclose(ft_kijun, pt_kijun, atol=1e-8) + + +class TestKELTNER_CHANNELSVsPandasTA: + """KELTNER_CHANNELS — Tail 30% match (Middle=EMA, bands=EMA±mult*ATR).""" + + def test_keltner_tail_convergence(self, ohlcv_500): + """Keltner Channels should converge in tail 30%.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 20 + atr_period = 10 + multiplier = 2.0 + + ft_upper, ft_middle, ft_lower = ferro_ta.KELTNER_CHANNELS( + high, + low, + close, + timeperiod=period, + atr_period=atr_period, + multiplier=multiplier, + ) + + # Compute manually using pandas_ta EMA and ATR to match ferro_ta's exact formula + pt_ema = pandas_ta.ema(pd.Series(close), length=period).to_numpy() + pt_atr = pandas_ta.atr( + pd.Series(high), pd.Series(low), pd.Series(close), length=atr_period + ).to_numpy() + pt_upper = pt_ema + multiplier * pt_atr + pt_middle = pt_ema + pt_lower = pt_ema - multiplier * pt_atr + + assert _allclose(ft_upper, pt_upper, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_middle, pt_middle, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_lower, pt_lower, atol=1e-2, tail_fraction=0.3) + + +class TestVWMAVsPandasTA: + """VWMA — Exact match (sum(c*v)/sum(v)).""" + + def test_vwma_exact_match(self, ohlcv_500): + """VWMA should match pandas-ta exactly.""" + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + period = 20 + + ft = ferro_ta.VWMA(close, volume, timeperiod=period) + + df = pd.DataFrame({"close": close, "volume": volume}) + pt = df.ta.vwma(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestCHOPPINESS_INDEXVsPandasTA: + """CHOPPINESS_INDEX — Close match (log10-based formula).""" + + def test_choppiness_index_close_match(self, ohlcv_500): + """Choppiness Index should match pandas-ta closely.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.CHOPPINESS_INDEX(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt = df.ta.chop(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-4) + + +class TestSUPERTRENDVsPandasTA: + """SUPERTREND — Direction >80% agreement (path-dependent, ATR seeding differs).""" + + def test_supertrend_direction_agreement(self, ohlcv_500): + """SUPERTREND direction should agree >80% of the time.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 7 + multiplier = 3.0 + + ft_line, ft_dir = ferro_ta.SUPERTREND( + high, low, close, timeperiod=period, multiplier=multiplier + ) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt_supertrend = df.ta.supertrend(length=period, multiplier=multiplier) + pt_dir = pt_supertrend[f"SUPERTd_{period}_{multiplier}"].to_numpy() + + # Convert directions to same format (1 = up, -1 = down) + # pandas-ta: 1 = uptrend, -1 = downtrend + # ferro_ta: 1 = uptrend, -1 = downtrend (assuming same convention) + + # Remove NaN values + mask = ~np.isnan(ft_dir) & ~np.isnan(pt_dir) + agreement_rate = np.mean(ft_dir[mask] == pt_dir[mask]) + + assert agreement_rate > 0.80, f"Direction agreement rate: {agreement_rate:.2%}" + + +class TestCHANDELIER_EXITVsPandasTA: + """CHANDELIER_EXIT — Exact structure (rolling_max(H)-mult*ATR).""" + + def test_chandelier_exit_structure_match(self, ohlcv_500): + """Chandelier Exit should match pandas-ta structure.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 22 + multiplier = 3.0 + + ft_long, ft_short = ferro_ta.CHANDELIER_EXIT( + high, low, close, timeperiod=period, multiplier=multiplier + ) + + # Compute manually: long = rolling_max(H, n) - mult*ATR; short = rolling_min(L, n) + mult*ATR + pt_atr = pandas_ta.atr( + pd.Series(high), pd.Series(low), pd.Series(close), length=period + ).to_numpy() + rolling_high = pd.Series(high).rolling(period).max().to_numpy() + rolling_low = pd.Series(low).rolling(period).min().to_numpy() + pt_long = rolling_high - multiplier * pt_atr + pt_short = rolling_low + multiplier * pt_atr + + assert _allclose(ft_long, pt_long, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_short, pt_short, atol=1e-2, tail_fraction=0.3) + + +class TestPIVOT_POINTSVsPandasTA: + """PIVOT_POINTS — Exact match for Classic (arithmetic formula).""" + + def test_pivot_points_classic_exact(self, ohlcv_500): + """Classic Pivot Points should match manually-computed reference.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + + ft_pivot, ft_r1, ft_s1, ft_r2, ft_s2 = ferro_ta.PIVOT_POINTS( + high, low, close, method="classic" + ) + + # ferro_ta PIVOT_POINTS uses previous bar's H/L/C (1-bar forward shift). + # Reference values are computed from bar i-1 to match index i output. + pivot = np.empty_like(high, dtype=float) + pivot[0] = np.nan + pivot[1:] = (high[:-1] + low[:-1] + close[:-1]) / 3.0 + + r1 = np.empty_like(high, dtype=float) + r1[0] = np.nan + r1[1:] = 2 * pivot[1:] - low[:-1] + + s1 = np.empty_like(high, dtype=float) + s1[0] = np.nan + s1[1:] = 2 * pivot[1:] - high[:-1] + + r2 = np.empty_like(high, dtype=float) + r2[0] = np.nan + r2[1:] = pivot[1:] + (high[:-1] - low[:-1]) + + s2 = np.empty_like(high, dtype=float) + s2[0] = np.nan + s2[1:] = pivot[1:] - (high[:-1] - low[:-1]) + + assert _allclose(ft_pivot, pivot, atol=1e-8) + assert _allclose(ft_r1, r1, atol=1e-8) + assert _allclose(ft_s1, s1, atol=1e-8) + assert _allclose(ft_r2, r2, atol=1e-8) + assert _allclose(ft_s2, s2, atol=1e-8) diff --git a/ferro-ta-main/tests/integration/test_vs_ta.py b/ferro-ta-main/tests/integration/test_vs_ta.py new file mode 100644 index 0000000..65e9fee --- /dev/null +++ b/ferro-ta-main/tests/integration/test_vs_ta.py @@ -0,0 +1,291 @@ +""" +Comparison tests: ferro_ta vs ta (Bukosabino's library) (Priority 5 - requires ta). + +Secondary cross-check using Bukosabino's ta library. Validates same indicators +from a second independent implementation. This is shorter (~200 lines) and +focused on highest-value duplicates. + +Requirements +------------ +Install ta before running these tests:: + + pip install ta + +The tests are automatically skipped when ta is not installed. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Skip the whole module when ta is not available +# --------------------------------------------------------------------------- + +ta = pytest.importorskip( + "ta", reason="ta library not installed; skipping comparison tests" +) +pd = pytest.importorskip("pandas", reason="pandas required for ta") + +import ferro_ta # noqa: E402 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _valid_mask(*arrays: np.ndarray) -> np.ndarray: + """Return boolean mask for positions where *all* arrays are finite.""" + mask = np.ones(len(arrays[0]), dtype=bool) + for a in arrays: + mask &= ~np.isnan(a) + return mask + + +def _allclose( + a: np.ndarray, b: np.ndarray, atol: float = 1e-6, tail_fraction: float = 1.0 +) -> bool: + """Compare arrays within tolerance, optionally only comparing tail.""" + mask = _valid_mask(a, b) + if not mask.any(): + return False + + if tail_fraction < 1.0: + n = len(a) + start_idx = int(n * (1 - tail_fraction)) + mask[:start_idx] = False + + if not mask.any(): + return False + + return bool(np.allclose(a[mask], b[mask], atol=atol)) + + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- + + +class TestSMAVsTA: + """SMA — Exact match.""" + + def test_sma_exact_match(self, ohlcv_500): + """SMA should match ta library exactly.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.SMA(close, timeperiod=period) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.trend.SMAIndicator(close=df["close"], window=period) + ta_result = ta_indicator.sma_indicator().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-8) + + +class TestEMAVsTA: + """EMA — Tail 30% match.""" + + def test_ema_tail_convergence(self, ohlcv_500): + """EMA should converge in tail 30%.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.EMA(close, timeperiod=period) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.trend.EMAIndicator(close=df["close"], window=period) + ta_result = ta_indicator.ema_indicator().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-4, tail_fraction=0.3) + + +class TestBBANDSVsTA: + """BBANDS — Exact match.""" + + def test_bbands_exact_match(self, ohlcv_500): + """Bollinger Bands should match ta library exactly.""" + close = ohlcv_500["close"] + period = 20 + nbdev = 2.0 + + ft_upper, ft_middle, ft_lower = ferro_ta.BBANDS( + close, timeperiod=period, nbdevup=nbdev, nbdevdn=nbdev + ) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.volatility.BollingerBands( + close=df["close"], window=period, window_dev=nbdev + ) + ta_upper = ta_indicator.bollinger_hband().to_numpy() + ta_middle = ta_indicator.bollinger_mavg().to_numpy() + ta_lower = ta_indicator.bollinger_lband().to_numpy() + + assert _allclose(ft_upper, ta_upper, atol=1e-8) + assert _allclose(ft_middle, ta_middle, atol=1e-8) + assert _allclose(ft_lower, ta_lower, atol=1e-8) + + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- + + +class TestRSIVsTA: + """RSI — Tail 30% match.""" + + def test_rsi_tail_convergence(self, ohlcv_500): + """RSI should converge in tail 30%.""" + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.RSI(close, timeperiod=period) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.momentum.RSIIndicator(close=df["close"], window=period) + ta_result = ta_indicator.rsi().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-3, tail_fraction=0.3) + + +class TestMACDVsTA: + """MACD — Tail 30% match.""" + + def test_macd_tail_convergence(self, ohlcv_500): + """MACD should converge in tail 30%.""" + close = ohlcv_500["close"] + + ft_macd, ft_signal, ft_hist = ferro_ta.MACD( + close, fastperiod=12, slowperiod=26, signalperiod=9 + ) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.trend.MACD( + close=df["close"], window_slow=26, window_fast=12, window_sign=9 + ) + ta_macd = ta_indicator.macd().to_numpy() + ta_signal = ta_indicator.macd_signal().to_numpy() + ta_hist = ta_indicator.macd_diff().to_numpy() + + assert _allclose(ft_macd, ta_macd, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_signal, ta_signal, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_hist, ta_hist, atol=1e-2, tail_fraction=0.3) + + +class TestSTOCHVsTA: + """STOCH — Structural validation (algorithms are incompatible with ta library). + + Note: the ``ta`` library's StochasticOscillator uses simple rolling-mean (SMA) + smoothing, while ferro_ta follows TA-Lib and applies Wilder's exponential smoothing. + The two approaches produce values that diverge by up to 30 percentage points, so + a direct numeric comparison is meaningless. Instead we validate structural + properties that every correct STOCH implementation must satisfy. + """ + + def test_stoch_structural_properties(self, ohlcv_500): + """STOCH output satisfies range and warm-up constraints.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + + ft_slowk, ft_slowd = ferro_ta.STOCH( + high, low, close, fastk_period=14, slowk_period=3, slowd_period=3 + ) + + # Values in valid region must be within [0, 100] + valid_k = ft_slowk[np.isfinite(ft_slowk)] + valid_d = ft_slowd[np.isfinite(ft_slowd)] + assert len(valid_k) > 0, "STOCH slowk should have valid values" + assert len(valid_d) > 0, "STOCH slowd should have valid values" + assert np.all(valid_k >= 0.0) and np.all(valid_k <= 100.0), ( + "STOCH slowk must be in [0, 100]" + ) + assert np.all(valid_d >= 0.0) and np.all(valid_d <= 100.0), ( + "STOCH slowd must be in [0, 100]" + ) + + # Warm-up: TA-Lib STOCH NaN count = fastk_period + slowk_period - 1 + expected_nan = ( + 14 + 3 + 1 - 1 + ) # = fastk_period + slowk_period (TA-Lib convention) + actual_nan_k = int(np.sum(np.isnan(ft_slowk))) + assert actual_nan_k == expected_nan, ( + f"STOCH slowk NaN warmup: expected {expected_nan}, got {actual_nan_k}" + ) + + +class TestWILLRVsTA: + """WILLR — Exact match.""" + + def test_willr_exact_match(self, ohlcv_500): + """Williams %R should match ta library exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.WILLR(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + ta_indicator = ta.momentum.WilliamsRIndicator( + high=df["high"], low=df["low"], close=df["close"], lbp=period + ) + ta_result = ta_indicator.williams_r().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-8) + + +# --------------------------------------------------------------------------- +# Volatility +# --------------------------------------------------------------------------- + + +class TestATRVsTA: + """ATR — Tail 30% match.""" + + def test_atr_tail_convergence(self, ohlcv_500): + """ATR should converge in tail 30%.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.ATR(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + ta_indicator = ta.volatility.AverageTrueRange( + high=df["high"], low=df["low"], close=df["close"], window=period + ) + ta_result = ta_indicator.average_true_range().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-2, tail_fraction=0.3) + + +# --------------------------------------------------------------------------- +# Volume +# --------------------------------------------------------------------------- + + +class TestOBVVsTA: + """OBV — Incremental match.""" + + def test_obv_incremental_match(self, ohlcv_500): + """OBV differences should match.""" + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + + ft = ferro_ta.OBV(close, volume) + + df = pd.DataFrame({"close": close, "volume": volume}) + ta_indicator = ta.volume.OnBalanceVolumeIndicator( + close=df["close"], volume=df["volume"] + ) + ta_result = ta_indicator.on_balance_volume().to_numpy() + + # Compare differences (OBV can have different starting values) + ft_diff = np.diff(ft) + ta_diff = np.diff(ta_result) + + mask = ~np.isnan(ft_diff) & ~np.isnan(ta_diff) + assert np.allclose(ft_diff[mask], ta_diff[mask], atol=1e-8) diff --git a/ferro-ta-main/tests/integration/test_vs_talib.py b/ferro-ta-main/tests/integration/test_vs_talib.py new file mode 100644 index 0000000..756762d --- /dev/null +++ b/ferro-ta-main/tests/integration/test_vs_talib.py @@ -0,0 +1,2178 @@ +""" +Comparison tests: ferro_ta vs TA-Lib (ta-lib Python wrapper). + +This module verifies that ferro_ta is a drop-in replacement for TA-Lib by +comparing the outputs of every shared indicator for: + + * **Shape compatibility** — same output length and NaN count (or ±1 where a + documented off-by-one exists). + * **Value accuracy** — exact match within floating-point tolerance where the + algorithms are identical; range / convergence checks where initialization + differs. + +Known differences are documented next to each test so consumers know what +to expect when migrating from TA-Lib. + +Requirements +------------ +Install ta-lib before running these tests:: + + pip install ta-lib + +The tests are automatically skipped when ta-lib is not installed, so the +main CI pipeline never fails because of a missing optional dependency. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Skip the whole module when ta-lib is not available +# --------------------------------------------------------------------------- + +talib = pytest.importorskip( + "talib", reason="ta-lib not installed; skipping comparison tests" +) + +import ferro_ta # noqa: E402 (import after potential skip) + +# --------------------------------------------------------------------------- +# Shared realistic OHLCV data (500 bars for proper convergence) +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) + +N = 500 # Increased from 100 to 500 for proper EMA/RSI/ATR convergence +CLOSE = 44.0 + np.cumsum(RNG.standard_normal(N) * 0.5) +HIGH = CLOSE + RNG.uniform(0.1, 1.0, N) +LOW = CLOSE - RNG.uniform(0.1, 1.0, N) +OPEN = CLOSE + RNG.standard_normal(N) * 0.2 +VOLUME = RNG.uniform(500.0, 2000.0, N) + +# Simple monotonically increasing series used for deterministic checks +LINEAR = np.arange(1.0, N + 1.0, dtype=np.float64) +LINEAR_HIGH = LINEAR + 0.5 +LINEAR_LOW = LINEAR - 0.5 +LINEAR_OPEN = LINEAR - 0.2 +LINEAR_VOL = np.ones(N) * 1000.0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Minimum fraction of values that must agree in sign for correlated indicators. +SIGN_AGREEMENT_THRESHOLD = 0.8 + +# Per-pattern candlestick agreement thresholds. +# Most patterns use 0.80; patterns with known definition differences from TA-Lib +# use lower thresholds with a documented reason. +CDL_AGREEMENT_THRESHOLDS: dict[str, float] = { + # Body/shadow ratio thresholds differ between ferro_ta and TA-Lib + "CDLHIGHWAVE": 0.65, # Shadow length threshold differs; 69% observed + "CDLLONGLEGGEDDOJI": 0.70, # Long-leg threshold differs; 75% observed + "CDLSHORTLINE": 0.20, # Body-size cutoff definition completely differs; 25% observed + "CDLSPINNINGTOP": 0.75, # Body ratio threshold differs; 78% observed + "CDLDOJI": 0.85, # Shadow ratio precision differs; 86% observed +} + + +def _nan_count(arr: np.ndarray) -> int: + return int(np.sum(np.isnan(arr))) + + +def _valid_mask(*arrays: np.ndarray) -> np.ndarray: + """Return boolean mask for positions where *all* arrays are finite.""" + mask = np.ones(len(arrays[0]), dtype=bool) + for a in arrays: + mask &= ~np.isnan(a) + return mask + + +def _allclose(a: np.ndarray, b: np.ndarray, atol: float = 1e-6) -> bool: + mask = _valid_mask(a, b) + if not mask.any(): + return False + return bool(np.allclose(a[mask], b[mask], atol=atol)) + + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- + + +class TestSMA: + """SMA — exact match.""" + + def test_values_match(self): + ft = ferro_ta.SMA(CLOSE, timeperiod=10) + ta = talib.SMA(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.SMA(CLOSE, timeperiod=10) + ta = talib.SMA(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.SMA(CLOSE, timeperiod=5) + ta = talib.SMA(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + +class TestEMA: + """EMA — shape matches; values differ slightly in the warmup region. + + ferro_ta seeds the EMA from the very first data point using the standard + recursive formula, while TA-Lib seeds the first EMA value with the SMA + of the initial ``timeperiod`` bars. After enough bars the two series + converge. We verify: + + * Same NaN count (warmup length is identical). + * After the series converge (last 30 % of the output) values agree. + """ + + def test_nan_count_match(self): + ft = ferro_ta.EMA(CLOSE, timeperiod=10) + ta = talib.EMA(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.EMA(CLOSE, timeperiod=5) + ta = talib.EMA(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + def test_values_converge(self): + """After convergence (tail 30%), EMA should be very close with 500 bars.""" + ft = ferro_ta.EMA(CLOSE, timeperiod=5) + ta = talib.EMA(CLOSE, timeperiod=5) + # With 500 bars, compare last 30% with tighter tolerance + tail_start = int(N * 0.7) + assert np.allclose( + ft[tail_start:], ta[tail_start:], atol=1e-5 + ) # Tightened from 1e-3 + + def test_values_finite_and_reasonable(self): + ft = ferro_ta.EMA(CLOSE, timeperiod=5) + finite = ft[~np.isnan(ft)] + assert finite.min() > 0 + assert finite.max() < 1000 + + +class TestWMA: + """WMA — exact match.""" + + def test_values_match(self): + ft = ferro_ta.WMA(CLOSE, timeperiod=10) + ta = talib.WMA(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.WMA(CLOSE, timeperiod=10) + ta = talib.WMA(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestDEMA: + """DEMA — shape matches; values differ (EMA-based initialization).""" + + def test_nan_count_match(self): + ft = ferro_ta.DEMA(CLOSE, timeperiod=5) + ta = talib.DEMA(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.DEMA(CLOSE, timeperiod=5) + ta = talib.DEMA(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + def test_values_converge(self): + ft = ferro_ta.DEMA(CLOSE, timeperiod=5) + ta = talib.DEMA(CLOSE, timeperiod=5) + mid = N // 2 + assert np.allclose(ft[mid:], ta[mid:], atol=1e-2) + + +class TestTEMA: + """TEMA — shape matches; values differ (EMA-based initialization).""" + + def test_nan_count_match(self): + ft = ferro_ta.TEMA(CLOSE, timeperiod=5) + ta = talib.TEMA(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.TEMA(CLOSE, timeperiod=5) + ta = talib.TEMA(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + def test_values_converge(self): + ft = ferro_ta.TEMA(CLOSE, timeperiod=5) + ta = talib.TEMA(CLOSE, timeperiod=5) + mid = N // 2 + assert np.allclose(ft[mid:], ta[mid:], atol=1e-2) + + +class TestTRIMA: + """TRIMA — exact match.""" + + def test_values_match(self): + ft = ferro_ta.TRIMA(CLOSE, timeperiod=10) + ta = talib.TRIMA(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.TRIMA(CLOSE, timeperiod=10) + ta = talib.TRIMA(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestKAMA: + """KAMA — values match after the first bar. + + TA-Lib marks index ``timeperiod - 1`` as NaN (the last element of the + seed window), while ferro_ta emits a value there. All subsequent values + are identical. + """ + + def test_values_match_after_seed(self): + ft = ferro_ta.KAMA(CLOSE, timeperiod=10) + ta = talib.KAMA(CLOSE, timeperiod=10) + # Skip the one bar where TA-Lib is still NaN + start = max(_nan_count(ft), _nan_count(ta)) + 1 + assert np.allclose(ft[start:], ta[start:], atol=1e-8) + + def test_output_length_match(self): + ft = ferro_ta.KAMA(CLOSE, timeperiod=10) + ta = talib.KAMA(CLOSE, timeperiod=10) + assert len(ft) == len(ta) + + +class TestT3: + """T3 — shape matches; values differ (EMA-based initialization).""" + + def test_nan_count_match(self): + ft = ferro_ta.T3(CLOSE, timeperiod=5) + ta = talib.T3(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.T3(CLOSE, timeperiod=5) + ta = talib.T3(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + def test_values_converge(self): + ft = ferro_ta.T3(CLOSE, timeperiod=5) + ta = talib.T3(CLOSE, timeperiod=5) + # With 500 bars, use last 30% with tighter tolerance + tail_start = int(N * 0.7) + assert np.allclose( + ft[tail_start:], ta[tail_start:], atol=1e-3 + ) # Tightened from 5e-2 + + +class TestBBANDS: + """BBANDS — exact match.""" + + def test_values_match(self): + upper_ft, mid_ft, lower_ft = ferro_ta.BBANDS( + CLOSE, timeperiod=10, nbdevup=2.0, nbdevdn=2.0 + ) + upper_ta, mid_ta, lower_ta = talib.BBANDS( + CLOSE, timeperiod=10, nbdevup=2.0, nbdevdn=2.0 + ) + assert _allclose(upper_ft, upper_ta) + assert _allclose(mid_ft, mid_ta) + assert _allclose(lower_ft, lower_ta) + + def test_nan_count_match(self): + upper_ft, _, _ = ferro_ta.BBANDS(CLOSE, timeperiod=10) + upper_ta, _, _ = talib.BBANDS(CLOSE, timeperiod=10) + assert _nan_count(upper_ft) == _nan_count(upper_ta) + + def test_output_length_match(self): + upper_ft, _, _ = ferro_ta.BBANDS(CLOSE, timeperiod=5) + upper_ta, _, _ = talib.BBANDS(CLOSE, timeperiod=5) + assert len(upper_ft) == len(upper_ta) + + +class TestMACD: + """MACD — shape matches; values differ (EMA-based initialization). + + The MACD line, signal, and histogram converge after sufficient warmup. + The histogram relationship (macd - signal) is preserved in both. + """ + + def test_nan_count_match(self): + ft_m, ft_s, ft_h = ferro_ta.MACD( + CLOSE, fastperiod=3, slowperiod=6, signalperiod=2 + ) + ta_m, ta_s, ta_h = talib.MACD(CLOSE, fastperiod=3, slowperiod=6, signalperiod=2) + assert _nan_count(ft_m) == _nan_count(ta_m) + + def test_output_length_match(self): + ft_m, ft_s, ft_h = ferro_ta.MACD(CLOSE) + ta_m, ta_s, ta_h = talib.MACD(CLOSE) + assert len(ft_m) == len(ta_m) == len(CLOSE) + + def test_histogram_relationship(self): + """Histogram = MACD line − signal line (must hold for both libraries).""" + for fn, lib in [(ferro_ta.MACD, "ferro_ta"), (talib.MACD, "talib")]: + m, s, h = fn(CLOSE, fastperiod=3, slowperiod=6, signalperiod=2) + mask = _valid_mask(m, s, h) + assert np.allclose(h[mask], m[mask] - s[mask], atol=1e-10), ( + f"{lib} histogram mismatch" + ) + + def test_values_converge(self): + ft_m, _, _ = ferro_ta.MACD(CLOSE, fastperiod=3, slowperiod=6, signalperiod=2) + ta_m, _, _ = talib.MACD(CLOSE, fastperiod=3, slowperiod=6, signalperiod=2) + assert np.allclose(ft_m[-N // 4 :], ta_m[-N // 4 :], atol=1e-2) + + +class TestMACDFIX: + """MACDFIX — shape matches; values differ (EMA-based initialization).""" + + def test_nan_count_match(self): + ft_m, ft_s, ft_h = ferro_ta.MACDFIX(CLOSE) + ta_m, ta_s, ta_h = talib.MACDFIX(CLOSE) + assert _nan_count(ft_m) == _nan_count(ta_m) + + def test_output_length_match(self): + ft_m, _, _ = ferro_ta.MACDFIX(CLOSE) + ta_m, _, _ = talib.MACDFIX(CLOSE) + assert len(ft_m) == len(ta_m) + + +class TestSAR: + """SAR — same output length; values may differ due to reversal history. + + Known difference: Parabolic SAR reversal history can diverge from TA-Lib + due to floating-point accumulation in early bars. Output shape (length, + NaN count) matches exactly. + """ + + def test_output_length_match(self): + ft = ferro_ta.SAR(HIGH, LOW) + ta = talib.SAR(HIGH, LOW) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.SAR(HIGH, LOW) + ta = talib.SAR(HIGH, LOW) + assert _nan_count(ft) == _nan_count(ta) + + def test_values_positive(self): + ft = ferro_ta.SAR(HIGH, LOW) + finite = ft[~np.isnan(ft)] + assert all(v > 0 for v in finite) + + def test_correlation_above_threshold(self): + """Correlated with TA-Lib even if not exact (same algorithm, different accumulation).""" + ft = ferro_ta.SAR(HIGH, LOW) + ta = talib.SAR(HIGH, LOW) + mask = _valid_mask(ft, ta) + if mask.sum() >= 5: + corr = float(np.corrcoef(ft[mask], ta[mask])[0, 1]) + assert corr > 0.90, f"SAR correlation {corr:.3f} < 0.90" + + +class TestSAREXT: + """SAREXT — SAR Extended. Shape must match; values may differ. + + Known difference: Same as SAR — reversal history from TA-Lib diverges + due to floating-point accumulation. + """ + + def test_output_length_match(self): + ft = ferro_ta.SAREXT(HIGH, LOW) + ta = talib.SAREXT(HIGH, LOW) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.SAREXT(HIGH, LOW) + ta = talib.SAREXT(HIGH, LOW) + assert _nan_count(ft) == _nan_count(ta) + + +class TestMAMA: + """MAMA — MESA Adaptive Moving Average. + + Known difference: TA-Lib C applies slightly different floating-point rounding + in the adaptive factor clamp. The two series are highly correlated (r > 0.95) + and values converge after ~100 bars, but differ numerically in early bars. + Status: ⚠️ Corr. + """ + + def test_output_length_match(self): + ft_m, ft_f = ferro_ta.MAMA(CLOSE) + ta_m, ta_f = talib.MAMA(CLOSE) + assert len(ft_m) == len(ta_m) + assert len(ft_f) == len(ta_f) + + def test_nan_count_match(self): + ft_m, ft_f = ferro_ta.MAMA(CLOSE) + ta_m, ta_f = talib.MAMA(CLOSE) + assert _nan_count(ft_m) == _nan_count(ta_m) + assert _nan_count(ft_f) == _nan_count(ta_f) + + def test_mama_correlated_with_talib(self): + """MAMA should be highly correlated with TA-Lib (r > 0.95).""" + ft_m, _ = ferro_ta.MAMA(CLOSE) + ta_m, _ = talib.MAMA(CLOSE) + mask = _valid_mask(ft_m, ta_m) + if mask.sum() >= 5: + corr = float(np.corrcoef(ft_m[mask], ta_m[mask])[0, 1]) + assert corr > 0.95, f"MAMA correlation {corr:.3f} < 0.95" + + def test_fama_correlated_with_talib(self): + """FAMA should be correlated with TA-Lib (r > 0.80).""" + _, ft_f = ferro_ta.MAMA(CLOSE) + _, ta_f = talib.MAMA(CLOSE) + mask = _valid_mask(ft_f, ta_f) + if mask.sum() >= 5: + corr = float(np.corrcoef(ft_f[mask], ta_f[mask])[0, 1]) + assert corr > 0.80, f"FAMA correlation {corr:.3f} < 0.80" + + def test_mama_converges_in_tail(self): + """After 100 bars the difference should be small (< 0.5% of price).""" + long_close = 44.0 + np.cumsum( + np.random.default_rng(99).standard_normal(200) * 0.5 + ) + ft_m, _ = ferro_ta.MAMA(long_close) + ta_m, _ = talib.MAMA(long_close) + mask = _valid_mask(ft_m, ta_m) + if mask.sum() >= 10: + tail = np.where(mask)[0][-min(10, mask.sum()) :] # last valid bars + diff = np.abs(ft_m[tail] - ta_m[tail]) + price_scale = np.abs(ta_m[tail]).mean() + assert (diff / price_scale).max() < 0.01, ( + f"MAMA tail relative diff: {(diff / price_scale).max():.4f}" + ) + + +class TestMIDPOINT: + """MIDPOINT — exact match.""" + + def test_values_match(self): + ft = ferro_ta.MIDPOINT(CLOSE, timeperiod=5) + ta = talib.MIDPOINT(CLOSE, timeperiod=5) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.MIDPOINT(CLOSE, timeperiod=5) + ta = talib.MIDPOINT(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + +class TestMIDPRICE: + """MIDPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.MIDPRICE(HIGH, LOW, timeperiod=5) + ta = talib.MIDPRICE(HIGH, LOW, timeperiod=5) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.MIDPRICE(HIGH, LOW, timeperiod=5) + ta = talib.MIDPRICE(HIGH, LOW, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- + + +class TestRSI: + """RSI — same NaN count and length; values differ due to Wilder smoothing seed. + + ferro_ta and TA-Lib use slightly different initializations for Wilder's + smoothed average gain/loss, leading to permanently different RSI values. + Both libraries produce values in [0, 100] with the same NaN structure. + """ + + def test_nan_count_match(self): + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_range_0_to_100(self): + for lib_rsi in [ferro_ta.RSI(CLOSE, 14), talib.RSI(CLOSE, 14)]: + finite = lib_rsi[~np.isnan(lib_rsi)] + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_values_same_direction(self): + """RSI should move in the same direction as TA-Lib (correlation > 0.9).""" + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.9 + + def test_values_converge_in_tail(self): + """With 500 bars, RSI should converge in tail 30%.""" + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + tail_start = int(N * 0.7) + mask = _valid_mask(ft[tail_start:], ta[tail_start:]) + if mask.any(): + assert np.allclose( + ft[tail_start:][mask], ta[tail_start:][mask], atol=1e-3 + ) # Added value comparison + + +class TestMOM: + """MOM — exact match.""" + + def test_values_match(self): + ft = ferro_ta.MOM(CLOSE, timeperiod=10) + ta = talib.MOM(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.MOM(CLOSE, timeperiod=10) + ta = talib.MOM(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestROC: + """ROC — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ROC(CLOSE, timeperiod=10) + ta = talib.ROC(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.ROC(CLOSE, timeperiod=10) + ta = talib.ROC(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestROCP: + """ROCP — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ROCP(CLOSE, timeperiod=10) + ta = talib.ROCP(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestROCR: + """ROCR — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ROCR(CLOSE, timeperiod=10) + ta = talib.ROCR(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestROCR100: + """ROCR100 — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ROCR100(CLOSE, timeperiod=10) + ta = talib.ROCR100(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestWILLR: + """WILLR — exact match.""" + + def test_values_match(self): + ft = ferro_ta.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_range_minus100_to_0(self): + ft = ferro_ta.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + for arr in [ft, ta]: + finite = arr[~np.isnan(arr)] + assert all(-100.0 <= v <= 0.0 for v in finite) + + +class TestAROON: + """AROON — exact match.""" + + def test_values_match(self): + ft_down, ft_up = ferro_ta.AROON(HIGH, LOW, timeperiod=14) + ta_down, ta_up = talib.AROON(HIGH, LOW, timeperiod=14) + assert _allclose(ft_down, ta_down) and _allclose(ft_up, ta_up) + + def test_nan_count_match(self): + ft_down, ft_up = ferro_ta.AROON(HIGH, LOW, timeperiod=14) + ta_down, ta_up = talib.AROON(HIGH, LOW, timeperiod=14) + assert _nan_count(ft_down) == _nan_count(ta_down) + + def test_range_0_to_100(self): + ft_down, ft_up = ferro_ta.AROON(HIGH, LOW, timeperiod=14) + for arr in [ft_down, ft_up]: + finite = arr[~np.isnan(arr)] + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestAROONOSC: + """AROONOSC — exact match.""" + + def test_values_match(self): + ft = ferro_ta.AROONOSC(HIGH, LOW, timeperiod=14) + ta = talib.AROONOSC(HIGH, LOW, timeperiod=14) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.AROONOSC(HIGH, LOW, timeperiod=14) + ta = talib.AROONOSC(HIGH, LOW, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + +class TestCCI: + """CCI — same NaN count and shape; mean-absolute-deviation may differ. + + TA-Lib divides by 0.015 × MAD computed with the population formula. + ferro_ta may use a slightly different MAD implementation, producing + proportionally scaled but directionally identical values. + """ + + def test_nan_count_match(self): + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_same_sign(self): + """CCI values should have the same sign as TA-Lib.""" + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + # Both should agree on whether CCI is positive/negative + assert ( + np.sum(np.sign(ft[mask]) == np.sign(ta[mask])) + > SIGN_AGREEMENT_THRESHOLD * mask.sum() + ) + + def test_values_strongly_correlated(self): + """CCI values should be strongly correlated with TA-Lib values.""" + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + +class TestBOP: + """BOP — exact match.""" + + def test_values_match(self): + ft = ferro_ta.BOP(OPEN, HIGH, LOW, CLOSE) + ta = talib.BOP(OPEN, HIGH, LOW, CLOSE) + assert _allclose(ft, ta) + + def test_output_length_match(self): + ft = ferro_ta.BOP(OPEN, HIGH, LOW, CLOSE) + ta = talib.BOP(OPEN, HIGH, LOW, CLOSE) + assert len(ft) == len(ta) + + +class TestMFI: + """MFI — values match on a well-constructed series. + + MFI (Money Flow Index) is computed from OHLCV and should agree exactly + when the typical prices and volumes are not degenerate. + """ + + def test_nan_count_match(self): + ft = ferro_ta.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + ta = talib.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_range_0_to_100(self): + ft = ferro_ta.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_values_match(self): + ft = ferro_ta.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + ta = talib.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + assert _allclose(ft, ta) + + +class TestSTOCHF: + """STOCHF — fast %K values match exactly. + + Note: ferro_ta uses ``fastk_period - 1`` NaNs while TA-Lib uses + ``fastk_period + fastd_period - 2`` NaNs (i.e., it waits for both %K + and %D to be valid before emitting anything). The overlapping valid + region is identical. + """ + + def test_fastk_values_match(self): + ft_k, ft_d = ferro_ta.STOCHF(HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3) + ta_k, ta_d = talib.STOCHF( + HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3, fastd_matype=0 + ) + assert _allclose(ft_k, ta_k) + + def test_output_length_match(self): + ft_k, _ = ferro_ta.STOCHF(HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3) + ta_k, _ = talib.STOCHF( + HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3, fastd_matype=0 + ) + assert len(ft_k) == len(ta_k) + + def test_range_0_to_100(self): + ft_k, ft_d = ferro_ta.STOCHF(HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3) + for arr in [ft_k, ft_d]: + finite = arr[~np.isnan(arr)] + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestSTOCH: + """STOCH — same shape; slow %K may differ by EMA initialisation.""" + + def test_output_length_match(self): + ft_k, ft_d = ferro_ta.STOCH(HIGH, LOW, CLOSE) + ta_k, ta_d = talib.STOCH(HIGH, LOW, CLOSE) + assert len(ft_k) == len(ta_k) + + def test_range_0_to_100(self): + ft_k, ft_d = ferro_ta.STOCH(HIGH, LOW, CLOSE) + for arr in [ft_k, ft_d]: + finite = arr[~np.isnan(arr)] + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestSTOCHRSI: + """STOCHRSI — same length; NaN count may differ by up to 2. + + The RSI seed difference propagates into StochRSI. ferro_ta emits values + sooner (fewer NaN) than TA-Lib in some configurations. + """ + + def test_output_length_match(self): + ft_k, ft_d = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + ta_k, ta_d = talib.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3, fastd_matype=0 + ) + assert len(ft_k) == len(ta_k) + + def test_nan_count_within_tolerance(self): + ft_k, ft_d = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + ta_k, ta_d = talib.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3, fastd_matype=0 + ) + assert abs(_nan_count(ft_k) - _nan_count(ta_k)) <= 2 + + def test_range_0_to_100(self): + ft_k, _ = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + finite = ft_k[~np.isnan(ft_k)] + # Allow small numerical tolerance for float boundaries + assert all(-1e-9 <= v <= 100.0 + 1e-9 for v in finite) + + +class TestAPO: + """APO — shape matches; values differ (EMA-based when matype != SMA).""" + + def test_nan_count_match(self): + ft = ferro_ta.APO(CLOSE, fastperiod=12, slowperiod=26) + ta = talib.APO(CLOSE, fastperiod=12, slowperiod=26, matype=0) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.APO(CLOSE, fastperiod=12, slowperiod=26) + ta = talib.APO(CLOSE, fastperiod=12, slowperiod=26, matype=0) + assert len(ft) == len(ta) + + +class TestPPO: + """PPO — ferro_ta returns (ppo, signal, histogram); TA-Lib returns only ppo. + + ferro_ta extends PPO with a signal line and histogram (similar to MACD), + while TA-Lib's PPO only returns the percentage-difference line. We verify + the output length and that all three ferro_ta arrays have valid shapes. + The ppo line converges toward the TA-Lib value after the EMA seed window. + """ + + def test_output_is_tuple_of_three(self): + result = ferro_ta.PPO(CLOSE, fastperiod=12, slowperiod=26) + assert isinstance(result, tuple) and len(result) == 3 + + def test_output_length_match(self): + ppo, signal, hist = ferro_ta.PPO(CLOSE, fastperiod=12, slowperiod=26) + ta = talib.PPO(CLOSE, fastperiod=12, slowperiod=26, matype=0) + assert len(ppo) == len(ta) + + def test_all_arrays_same_length(self): + ppo, signal, hist = ferro_ta.PPO(CLOSE, fastperiod=12, slowperiod=26) + assert len(ppo) == len(signal) == len(hist) == N + + def test_ppo_converges_to_talib(self): + """PPO line should be strongly correlated with TA-Lib's PPO output. + + Note: EMA seeding differences mean correlation is ~0.90 for short periods. + We verify > 0.85 to confirm same signal direction. + """ + ppo, _, _ = ferro_ta.PPO(CLOSE, fastperiod=3, slowperiod=6) + ta = talib.PPO(CLOSE, fastperiod=3, slowperiod=6, matype=0) + mask = _valid_mask(ppo, ta) + corr = np.corrcoef(ppo[mask], ta[mask])[0, 1] + assert corr > 0.85 + + """CMO — same NaN count and shape; values may differ slightly. + + Both libraries compute the Chande Momentum Oscillator as + (sum_up - sum_dn) / (sum_up + sum_dn) × 100, but use different rolling + window implementations (TA-Lib uses Wilder's smoothing for the gains/ + losses; ferro_ta uses a plain rolling sum). Values are strongly + correlated but not numerically identical. + """ + + def test_nan_count_match(self): + ft = ferro_ta.CMO(CLOSE, timeperiod=14) + ta = talib.CMO(CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.CMO(CLOSE, timeperiod=14) + ta = talib.CMO(CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_range_minus100_to_100(self): + ft = ferro_ta.CMO(CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(-100.0 <= v <= 100.0 for v in finite) + + def test_values_strongly_correlated(self): + ft = ferro_ta.CMO(CLOSE, timeperiod=14) + ta = talib.CMO(CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.85 + + +class TestTRIX: + """TRIX — shape matches; values differ (triple EMA initialisation).""" + + def test_nan_count_match(self): + ft = ferro_ta.TRIX(CLOSE, timeperiod=5) + ta = talib.TRIX(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.TRIX(CLOSE, timeperiod=5) + ta = talib.TRIX(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + +class TestULTOSC: + """ULTOSC — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ULTOSC( + HIGH, LOW, CLOSE, timeperiod1=7, timeperiod2=14, timeperiod3=28 + ) + ta = talib.ULTOSC( + HIGH, LOW, CLOSE, timeperiod1=7, timeperiod2=14, timeperiod3=28 + ) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.ULTOSC(HIGH, LOW, CLOSE) + ta = talib.ULTOSC(HIGH, LOW, CLOSE) + assert _nan_count(ft) == _nan_count(ta) + + +class TestADX: + """ADX — same shape; values differ on random data (Wilder smoothing seed). + + On monotonically trending data the values match TA-Lib exactly. On + random price series the Wilder's smoothing seed for ATR and DM causes + permanent divergence (values do not converge). + """ + + def test_nan_count_match(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_range_0_to_100(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_values_strongly_correlated(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + +class TestADXR: + """ADXR — same shape (±1 NaN); values differ (Wilder smoothing seed). + + ADXR = (ADX[t] + ADX[t - timeperiod]) / 2. The ADX values differ from + TA-Lib due to the Wilder smoothing seed, so ADXR differs too. + """ + + def test_output_length_match(self): + ft = ferro_ta.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_nan_count_within_one(self): + ft = ferro_ta.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + assert abs(_nan_count(ft) - _nan_count(ta)) <= 1 + + def test_values_strongly_correlated(self): + ft = ferro_ta.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.95 + + +class TestDX: + """DX — same NaN count and shape; values differ on random data. + + DX = |+DI - -DI| / (+DI + -DI) × 100. The +DI and -DI values depend on + Wilder's smoothed ATR and DM, both of which have different seeds in + ferro_ta vs TA-Lib. Values are strongly correlated. + """ + + def test_nan_count_match(self): + ft = ferro_ta.DX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.DX(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.DX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.DX(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_range_0_to_100(self): + ft = ferro_ta.DX(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestPLUSDI: + """PLUS_DI — same NaN count; values differ on random data (Wilder smoothing).""" + + def test_nan_count_match(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_non_negative(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v >= 0.0 for v in finite) + + +class TestMINUSDI: + """MINUS_DI — same NaN count; values differ on random data (Wilder smoothing).""" + + def test_nan_count_match(self): + ft = ferro_ta.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_non_negative(self): + ft = ferro_ta.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v >= 0.0 for v in finite) + + +class TestPLUSDM: + """PLUS_DM — values match in the non-degenerate (OHLCV) region.""" + + def test_output_length_match(self): + ft = ferro_ta.PLUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.PLUS_DM(HIGH, LOW, timeperiod=14) + assert len(ft) == len(ta) + + def test_non_negative(self): + ft = ferro_ta.PLUS_DM(HIGH, LOW, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v >= 0.0 for v in finite) + + +class TestMINUSDM: + """MINUS_DM — same length; NaN count may differ by 1 (Wilder smoothing seed).""" + + def test_output_length_match(self): + ft = ferro_ta.MINUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.MINUS_DM(HIGH, LOW, timeperiod=14) + assert len(ft) == len(ta) + + def test_nan_count_within_one(self): + ft = ferro_ta.MINUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.MINUS_DM(HIGH, LOW, timeperiod=14) + assert abs(_nan_count(ft) - _nan_count(ta)) <= 1 + + def test_non_negative(self): + ft = ferro_ta.MINUS_DM(HIGH, LOW, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v >= 0.0 for v in finite) + + +# --------------------------------------------------------------------------- +# Volume Indicators +# --------------------------------------------------------------------------- + + +class TestAD: + """AD — exact match.""" + + def test_values_match(self): + ft = ferro_ta.AD(HIGH, LOW, CLOSE, VOLUME) + ta = talib.AD(HIGH, LOW, CLOSE, VOLUME) + assert _allclose(ft, ta) + + def test_output_length_match(self): + ft = ferro_ta.AD(HIGH, LOW, CLOSE, VOLUME) + ta = talib.AD(HIGH, LOW, CLOSE, VOLUME) + assert len(ft) == len(ta) + + +class TestADOSC: + """ADOSC — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ADOSC(HIGH, LOW, CLOSE, VOLUME, fastperiod=3, slowperiod=10) + ta = talib.ADOSC(HIGH, LOW, CLOSE, VOLUME, fastperiod=3, slowperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.ADOSC(HIGH, LOW, CLOSE, VOLUME) + ta = talib.ADOSC(HIGH, LOW, CLOSE, VOLUME) + assert _nan_count(ft) == _nan_count(ta) + + +class TestOBV: + """OBV — values match after the first bar. + + TA-Lib starts OBV accumulation at the *first* bar (OBV[0] = volume[0] if + price rose, else -volume[0]). ferro_ta initialises OBV[0] = 0 and applies + the direction rule from bar 1 onward. All increments are identical; the + two series differ only by a constant offset equal to the first OBV value. + """ + + def test_output_length_match(self): + ft = ferro_ta.OBV(CLOSE, VOLUME) + ta = talib.OBV(CLOSE, VOLUME) + assert len(ft) == len(ta) + + def test_increments_match(self): + """Day-over-day OBV changes must be identical.""" + ft = ferro_ta.OBV(CLOSE, VOLUME) + ta = talib.OBV(CLOSE, VOLUME) + ft_diff = np.diff(ft) + ta_diff = np.diff(ta) + assert np.allclose(ft_diff, ta_diff, atol=1e-8) + + def test_no_nans(self): + ft = ferro_ta.OBV(CLOSE, VOLUME) + assert not np.any(np.isnan(ft)) + + +# --------------------------------------------------------------------------- +# Volatility Indicators +# --------------------------------------------------------------------------- + + +class TestATR: + """ATR — same length; values differ (different Wilder smoothing seed). + + TA-Lib uses Wilder's smoothing and marks the very first ATR value (at + index ``timeperiod``) as NaN. ferro_ta emits a value there. The Wilder + recursion runs from a different seed, so values do not converge. Both + produce strongly correlated positive ATR values. + """ + + def test_output_length_match(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_nan_count_within_one(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + assert abs(_nan_count(ft) - _nan_count(ta)) <= 1 + + def test_values_positive(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v > 0 for v in finite) + + def test_values_strongly_correlated(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.95 + + +class TestNATR: + """NATR — same shape tolerance as ATR; values differ (Wilder smoothing seed).""" + + def test_output_length_match(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.NATR(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_nan_count_within_one(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.NATR(HIGH, LOW, CLOSE, timeperiod=14) + assert abs(_nan_count(ft) - _nan_count(ta)) <= 1 + + def test_values_positive(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v > 0 for v in finite) + + def test_values_strongly_correlated(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.NATR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.95 + + +class TestTRANGE: + """TRANGE — values match. + + TA-Lib emits NaN at index 0 (no previous close to compute true range). + ferro_ta emits TRANGE[0] = high[0] − low[0] (high-low only, no prior + close). From index 1 onward the values are identical. + """ + + def test_output_length_match(self): + ft = ferro_ta.TRANGE(HIGH, LOW, CLOSE) + ta = talib.TRANGE(HIGH, LOW, CLOSE) + assert len(ft) == len(ta) + + def test_values_match_after_first(self): + ft = ferro_ta.TRANGE(HIGH, LOW, CLOSE) + ta = talib.TRANGE(HIGH, LOW, CLOSE) + assert np.allclose(ft[1:], ta[1:], atol=1e-8) + + def test_values_positive(self): + ft = ferro_ta.TRANGE(HIGH, LOW, CLOSE) + assert all(v > 0 for v in ft[1:]) + + +# --------------------------------------------------------------------------- +# Statistical Functions +# --------------------------------------------------------------------------- + + +class TestSTDDEV: + """STDDEV — exact match.""" + + def test_values_match(self): + ft = ferro_ta.STDDEV(CLOSE, timeperiod=10) + ta = talib.STDDEV(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.STDDEV(CLOSE, timeperiod=10) + ta = talib.STDDEV(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestVAR: + """VAR — exact match.""" + + def test_values_match(self): + ft = ferro_ta.VAR(CLOSE, timeperiod=10) + ta = talib.VAR(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestLINEARREG: + """LINEARREG — exact match.""" + + def test_values_match(self): + ft = ferro_ta.LINEARREG(CLOSE, timeperiod=10) + ta = talib.LINEARREG(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.LINEARREG(CLOSE, timeperiod=10) + ta = talib.LINEARREG(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestLINEARREGSlope: + """LINEARREG_SLOPE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.LINEARREG_SLOPE(CLOSE, timeperiod=10) + ta = talib.LINEARREG_SLOPE(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestLINEARREGIntercept: + """LINEARREG_INTERCEPT — exact match.""" + + def test_values_match(self): + ft = ferro_ta.LINEARREG_INTERCEPT(CLOSE, timeperiod=10) + ta = talib.LINEARREG_INTERCEPT(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestLINEARREGAngle: + """LINEARREG_ANGLE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.LINEARREG_ANGLE(CLOSE, timeperiod=10) + ta = talib.LINEARREG_ANGLE(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestTSF: + """TSF — exact match.""" + + def test_values_match(self): + ft = ferro_ta.TSF(CLOSE, timeperiod=10) + ta = talib.TSF(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.TSF(CLOSE, timeperiod=10) + ta = talib.TSF(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestBETA: + """BETA — same shape; algorithm differs from TA-Lib. + + ferro_ta computes a simplified rolling beta (covariance / variance of the + reference series), while TA-Lib uses the standard CAPM beta estimator. + Shape compatibility (NaN count, length) is verified; exact value match is + not expected. + """ + + def test_output_length_match(self): + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + +class TestCORREL: + """CORREL — exact match.""" + + def test_values_match(self): + ft = ferro_ta.CORREL(CLOSE, HIGH, timeperiod=10) + ta = talib.CORREL(CLOSE, HIGH, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.CORREL(CLOSE, HIGH, timeperiod=10) + ta = talib.CORREL(CLOSE, HIGH, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + def test_range_minus1_to_1(self): + ft = ferro_ta.CORREL(CLOSE, HIGH, timeperiod=10) + finite = ft[~np.isnan(ft)] + assert all(-1.0 <= v <= 1.0 for v in finite) + + +# --------------------------------------------------------------------------- +# Price Transformations +# --------------------------------------------------------------------------- + + +class TestAVGPRICE: + """AVGPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.AVGPRICE(OPEN, HIGH, LOW, CLOSE) + ta = talib.AVGPRICE(OPEN, HIGH, LOW, CLOSE) + assert np.allclose(ft, ta, atol=1e-10) + + def test_output_length_match(self): + assert len(ferro_ta.AVGPRICE(OPEN, HIGH, LOW, CLOSE)) == N + + +class TestMEDPRICE: + """MEDPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.MEDPRICE(HIGH, LOW) + ta = talib.MEDPRICE(HIGH, LOW) + assert np.allclose(ft, ta, atol=1e-10) + + +class TestTYPPRICE: + """TYPPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.TYPPRICE(HIGH, LOW, CLOSE) + ta = talib.TYPPRICE(HIGH, LOW, CLOSE) + assert np.allclose(ft, ta, atol=1e-10) + + +class TestWCLPRICE: + """WCLPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.WCLPRICE(HIGH, LOW, CLOSE) + ta = talib.WCLPRICE(HIGH, LOW, CLOSE) + assert np.allclose(ft, ta, atol=1e-10) + + +# --------------------------------------------------------------------------- +# Pattern Recognition +# --------------------------------------------------------------------------- + + +class TestPatternShapeCompatibility: + """Patterns — same output length and dtype; values may differ. + + Pattern recognition algorithms depend heavily on thresholds and candle + body/shadow definitions. ferro_ta implements simplified versions of these + patterns. These tests verify that: + + * Output length matches TA-Lib. + * Values are restricted to {-100, 0, 100} (same convention as TA-Lib). + """ + + PATTERNS = [ + "CDLDOJI", + "CDLENGULFING", + "CDLHAMMER", + "CDLSHOOTINGSTAR", + "CDLMARUBOZU", + "CDLSPINNINGTOP", + "CDLMORNINGSTAR", + "CDLEVENINGSTAR", + "CDL2CROWS", + # Additional candlestick patterns + "CDL3BLACKCROWS", + "CDL3INSIDE", + "CDL3LINESTRIKE", + "CDL3OUTSIDE", + "CDL3STARSINSOUTH", + "CDL3WHITESOLDIERS", + "CDLABANDONEDBABY", + "CDLADVANCEBLOCK", + "CDLBELTHOLD", + "CDLBREAKAWAY", + "CDLCLOSINGMARUBOZU", + "CDLCONCEALBABYSWALL", + "CDLCOUNTERATTACK", + "CDLDARKCLOUDCOVER", + "CDLDOJISTAR", + "CDLDRAGONFLYDOJI", + "CDLGAPSIDESIDEWHITE", + "CDLGRAVESTONEDOJI", + "CDLHANGINGMAN", + "CDLHARAMI", + "CDLHARAMICROSS", + "CDLHIGHWAVE", + "CDLHIKKAKE", + "CDLHIKKAKEMOD", + "CDLHOMINGPIGEON", + "CDLIDENTICAL3CROWS", + "CDLINNECK", + "CDLINVERTEDHAMMER", + "CDLKICKING", + "CDLKICKINGBYLENGTH", + "CDLLADDERBOTTOM", + "CDLLONGLEGGEDDOJI", + "CDLLONGLINE", + "CDLMATCHINGLOW", + "CDLMATHOLD", + "CDLMORNINGDOJISTAR", + "CDLEVENINGDOJISTAR", + "CDLONNECK", + "CDLPIERCING", + "CDLRICKSHAWMAN", + "CDLRISEFALL3METHODS", + "CDLSEPARATINGLINES", + "CDLSHORTLINE", + "CDLSTALLEDPATTERN", + "CDLSTICKSANDWICH", + "CDLTAKURI", + "CDLTASUKIGAP", + "CDLTHRUSTING", + "CDLTRISTAR", + "CDLUNIQUE3RIVER", + "CDLUPSIDEGAP2CROWS", + "CDLXSIDEGAP3METHODS", + ] + + @pytest.mark.parametrize("name", PATTERNS) + def test_output_length_match(self, name: str): + ft_fn = getattr(ferro_ta, name) + ta_fn = getattr(talib, name) + ft = ft_fn(OPEN, HIGH, LOW, CLOSE) + ta = ta_fn(OPEN, HIGH, LOW, CLOSE) + assert len(ft) == len(ta) + + @pytest.mark.parametrize("name", PATTERNS) + def test_valid_output_values(self, name: str): + ft_fn = getattr(ferro_ta, name) + ft = ft_fn(OPEN, HIGH, LOW, CLOSE) + assert all(v in (-100, 0, 100) for v in ft), ( + f"{name}: unexpected values {set(ft)}" + ) + + def test_cdlengulfing_values_match(self): + """CDLENGULFING matches TA-Lib exactly on random OHLCV data.""" + ft = ferro_ta.CDLENGULFING(OPEN, HIGH, LOW, CLOSE) + ta = talib.CDLENGULFING(OPEN, HIGH, LOW, CLOSE) + assert np.array_equal(ft, ta) + + +# --------------------------------------------------------------------------- +# Parity suite additions +# --------------------------------------------------------------------------- + + +class TestParitySuite: + """ + Comprehensive parity validation against TA-Lib. + + Covers: + * Large-dataset SMA equivalence (10,000 rows) + * Strict shape and dtype checks for MACD and BBANDS + * float32 input handling (should cast safely via _to_f64) + """ + + # 10,000-row synthetic OHLCV data + N_LARGE = 10_000 + _rng = np.random.default_rng(2024) + CLOSE_LARGE = 100.0 + np.cumsum(_rng.standard_normal(N_LARGE) * 0.5) + + def test_sma_10k_allclose(self): + """SMA on 10,000 rows must match TA-Lib within floating-point tolerance.""" + ft = ferro_ta.SMA(self.CLOSE_LARGE, timeperiod=30) + ta = talib.SMA(self.CLOSE_LARGE, timeperiod=30) + assert np.allclose(ft, ta, equal_nan=True), "SMA mismatch on 10k-row dataset" + + def test_macd_shape_and_dtype(self): + """MACD output must have correct shape and float64 dtype.""" + macd_line, signal, hist = ferro_ta.MACD(CLOSE) + assert macd_line.shape == (N,) + assert signal.shape == (N,) + assert hist.shape == (N,) + assert macd_line.dtype == np.float64 + assert signal.dtype == np.float64 + assert hist.dtype == np.float64 + + def test_bbands_shape_and_dtype(self): + """BBANDS output must have correct shape and float64 dtype.""" + upper, middle, lower = ferro_ta.BBANDS(CLOSE, timeperiod=20) + assert upper.shape == (N,) + assert middle.shape == (N,) + assert lower.shape == (N,) + assert upper.dtype == np.float64 + assert middle.dtype == np.float64 + assert lower.dtype == np.float64 + + def test_float32_input_casts_safely(self): + """Passing float32 arrays should cast to float64 silently (no error).""" + close32 = CLOSE.astype(np.float32) + # _to_f64 should cast — result must be finite and match float64 version + result = ferro_ta.SMA(close32, timeperiod=10) + expected = ferro_ta.SMA(CLOSE, timeperiod=10) + assert result.dtype == np.float64 + valid = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[valid], expected[valid], atol=1e-4) + + def test_macd_nan_count_vs_talib(self): + """MACD NaN counts must agree with TA-Lib (same warmup period).""" + ft_m, ft_s, ft_h = ferro_ta.MACD(CLOSE) + ta_m, ta_s, ta_h = talib.MACD(CLOSE) + assert _nan_count(ft_m) == _nan_count(ta_m) + assert _nan_count(ft_s) == _nan_count(ta_s) + + def test_bbands_values_match_talib(self): + """BBANDS must match TA-Lib exactly (SMA-based, no EMA seeding issue).""" + ft_u, ft_m, ft_l = ferro_ta.BBANDS(CLOSE, timeperiod=20) + ta_u, ta_m, ta_l = talib.BBANDS(CLOSE, timeperiod=20) + assert _allclose(ft_u, ta_u), "BBANDS upper mismatch" + assert _allclose(ft_m, ta_m), "BBANDS middle mismatch" + assert _allclose(ft_l, ta_l), "BBANDS lower mismatch" + + +# --------------------------------------------------------------------------- +# Numerical parity — RSI, ATR, NATR, CCI, BETA alignment +# --------------------------------------------------------------------------- + + +class TestNumericalParity: + """Verify RSI, ATR, NATR, CCI, BETA alignment with TA-Lib.""" + + def test_rsi_output_length_matches(self): + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_rsi_nan_count_matches(self): + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta), ( + f"RSI NaN count: ferro_ta={_nan_count(ft)}, talib={_nan_count(ta)}" + ) + + def test_rsi_values_allclose(self): + """RSI values must match TA-Lib within tolerance after seeding.""" + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any(), "No valid bars to compare" + assert np.allclose(ft[mask], ta[mask], atol=1e-8), ( + f"RSI max diff: {np.abs(ft[mask] - ta[mask]).max()}" + ) + + def test_atr_output_length_matches(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_atr_nan_count_matches(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta), ( + f"ATR NaN count: ferro_ta={_nan_count(ft)}, talib={_nan_count(ta)}" + ) + + def test_atr_values_allclose(self): + """ATR values must match TA-Lib within tolerance.""" + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + assert np.allclose(ft[mask], ta[mask], atol=1e-8), ( + f"ATR max diff: {np.abs(ft[mask] - ta[mask]).max()}" + ) + + def test_natr_values_allclose(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.NATR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + assert np.allclose(ft[mask], ta[mask], atol=1e-6) + + def test_cci_output_length_matches(self): + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_cci_values_allclose(self): + """CCI values must match TA-Lib exactly.""" + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + assert np.allclose(ft[mask], ta[mask], atol=1e-6), ( + f"CCI max diff: {np.abs(ft[mask] - ta[mask]).max()}" + ) + + def test_beta_output_length_matches(self): + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + assert len(ft) == len(ta) + + def test_beta_nan_count_matches(self): + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta), ( + f"BETA NaN count: ferro_ta={_nan_count(ft)}, talib={_nan_count(ta)}" + ) + + def test_beta_values_close_to_talib(self): + """BETA values using returns-based regression must be close to TA-Lib.""" + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + mask = _valid_mask(ft, ta) + assert mask.any() + # TA-Lib BETA uses returns-based regression — allow small tolerance + assert np.allclose(ft[mask], ta[mask], atol=1e-8), ( + f"BETA max diff: {np.abs(ft[mask] - ta[mask]).max()}" + ) + + +# --------------------------------------------------------------------------- +# Math operators vs TA-Lib +# --------------------------------------------------------------------------- + + +class TestMathOperatorsVsTalib: + """Verify that math operator shims match TA-Lib exactly.""" + + def test_add_matches_talib(self): + ft = ferro_ta.ADD(CLOSE, HIGH) + ta = talib.ADD(CLOSE, HIGH) + assert np.allclose(ft, ta, equal_nan=True) + + def test_sub_matches_talib(self): + ft = ferro_ta.SUB(HIGH, LOW) + ta = talib.SUB(HIGH, LOW) + assert np.allclose(ft, ta, equal_nan=True) + + def test_mult_matches_talib(self): + ft = ferro_ta.MULT(CLOSE, VOLUME) + ta = talib.MULT(CLOSE, VOLUME) + assert np.allclose(ft, ta, equal_nan=True) + + def test_div_matches_talib(self): + ft = ferro_ta.DIV(CLOSE, HIGH) + ta = talib.DIV(CLOSE, HIGH) + assert np.allclose(ft, ta, equal_nan=True) + + def test_sum_matches_talib(self): + ft = ferro_ta.SUM(CLOSE, timeperiod=10) + ta = talib.SUM(CLOSE, timeperiod=10) + assert np.allclose(ft, ta, equal_nan=True) + + def test_max_matches_talib(self): + ft = ferro_ta.MAX(CLOSE, timeperiod=10) + ta = talib.MAX(CLOSE, timeperiod=10) + assert np.allclose(ft, ta, equal_nan=True) + + def test_min_matches_talib(self): + ft = ferro_ta.MIN(CLOSE, timeperiod=10) + ta = talib.MIN(CLOSE, timeperiod=10) + assert np.allclose(ft, ta, equal_nan=True) + + def test_sin_matches_talib(self): + ft = ferro_ta.SIN(CLOSE) + ta = talib.SIN(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + def test_cos_matches_talib(self): + ft = ferro_ta.COS(CLOSE) + ta = talib.COS(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + def test_sqrt_matches_talib(self): + ft = ferro_ta.SQRT(CLOSE) + ta = talib.SQRT(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + def test_exp_matches_talib(self): + ft = ferro_ta.EXP(LINEAR) + ta = talib.EXP(LINEAR) + assert np.allclose(ft, ta, equal_nan=True) + + def test_ln_matches_talib(self): + ft = ferro_ta.LN(CLOSE) + ta = talib.LN(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + def test_log10_matches_talib(self): + ft = ferro_ta.LOG10(CLOSE) + ta = talib.LOG10(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + +# --------------------------------------------------------------------------- +# STOCH, STOCHRSI, ADX, DI, DM parity +# --------------------------------------------------------------------------- + + +class TestDirectionalMovementVsTalib: + """Verify ADX, DX, +DI, -DI, +DM, -DM are strongly correlated with TA-Lib. + + Wilder smoothing seed differs between ferro_ta and TA-Lib, so values are + not numerically identical but must be strongly correlated. + """ + + def test_plus_di_output_length(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_plus_di_nan_count(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_plus_di_values_strongly_correlated(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_minus_di_values_strongly_correlated(self): + ft = ferro_ta.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_plus_dm_output_length(self): + ft = ferro_ta.PLUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.PLUS_DM(HIGH, LOW, timeperiod=14) + assert len(ft) == len(ta) + + def test_plus_dm_values_strongly_correlated(self): + ft = ferro_ta.PLUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.PLUS_DM(HIGH, LOW, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_minus_dm_values_strongly_correlated(self): + ft = ferro_ta.MINUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.MINUS_DM(HIGH, LOW, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_dx_values_strongly_correlated(self): + ft = ferro_ta.DX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.DX(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_adx_output_length(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_adx_nan_count(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_adx_values_strongly_correlated(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_adxr_values_strongly_correlated(self): + ft = ferro_ta.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.95 + + +class TestSTOCHVsTalib: + """Verify STOCH and STOCHRSI match TA-Lib.""" + + def test_stoch_slowk_output_length(self): + ft_k, _ = ferro_ta.STOCH(HIGH, LOW, CLOSE) + ta_k, _ = talib.STOCH(HIGH, LOW, CLOSE) + assert len(ft_k) == len(ta_k) + + def test_stoch_nan_count_matches(self): + ft_k, ft_d = ferro_ta.STOCH(HIGH, LOW, CLOSE) + ta_k, ta_d = talib.STOCH(HIGH, LOW, CLOSE) + assert _nan_count(ft_k) == _nan_count(ta_k) + assert _nan_count(ft_d) == _nan_count(ta_d) + + def test_stoch_values_allclose(self): + ft_k, ft_d = ferro_ta.STOCH(HIGH, LOW, CLOSE) + ta_k, ta_d = talib.STOCH(HIGH, LOW, CLOSE) + mask_k = _valid_mask(ft_k, ta_k) + mask_d = _valid_mask(ft_d, ta_d) + assert mask_k.any() + assert np.allclose(ft_k[mask_k], ta_k[mask_k], atol=1e-8) + assert np.allclose(ft_d[mask_d], ta_d[mask_d], atol=1e-8) + + def test_stochrsi_output_length(self): + ft_k, _ = ferro_ta.STOCHRSI(CLOSE) + ta_k, _ = talib.STOCHRSI(CLOSE) + assert len(ft_k) == len(ta_k) + + def test_stochrsi_nan_count_matches(self): + ft_k, ft_d = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + ta_k, ta_d = talib.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + # RSI seed difference can yield ±2 NaN count (see TestSTOCHRSI) + assert abs(_nan_count(ft_k) - _nan_count(ta_k)) <= 2 + + def test_stochrsi_values_close(self): + ft_k, ft_d = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + ta_k, ta_d = talib.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + mask_k = _valid_mask(ft_k, ta_k) + assert mask_k.any() + assert np.allclose(ft_k[mask_k], ta_k[mask_k], atol=1e-8) + + +# --------------------------------------------------------------------------- +# MAMA, SAR/SAREXT, and HT_* cycle indicator tests +# +# These indicators are documented as ⚠️ Corr or ⚠️ Shape in the README because +# TA-Lib C uses slightly different floating-point accumulation and clamping +# order. Tests enforce shape parity and minimum correlation rather than +# exact allclose. +# --------------------------------------------------------------------------- + + +class TestHTTrendline: + """HT_TRENDLINE — 63-bar lookback; values correlated with TA-Lib. + + Known difference: Ehlers HT filter — same algorithm and 63-bar lookback; + values are correlated (r > 0.90) but not numerically identical due to + different clamp order in TA-Lib C source. + """ + + def test_output_length_match(self): + ft = ferro_ta.HT_TRENDLINE(CLOSE) + ta = talib.HT_TRENDLINE(CLOSE) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.HT_TRENDLINE(CLOSE) + ta = talib.HT_TRENDLINE(CLOSE) + assert _nan_count(ft) == _nan_count(ta) + + def test_correlated_with_talib(self): + """HT_TRENDLINE should be highly correlated with TA-Lib output.""" + ft = ferro_ta.HT_TRENDLINE(CLOSE) + ta = talib.HT_TRENDLINE(CLOSE) + mask = _valid_mask(ft, ta) + if mask.sum() >= 5: + corr = float(np.corrcoef(ft[mask], ta[mask])[0, 1]) + assert corr > 0.90, f"HT_TRENDLINE correlation {corr:.3f} < 0.90" + + +class TestHTDCPeriod: + """HT_DCPERIOD — 63-bar lookback; shape parity enforced. + + Known difference: Dominant cycle period values correlated with TA-Lib + but not exact (same Ehlers algorithm, different floating-point accumulation). + """ + + def test_output_length_match(self): + ft = ferro_ta.HT_DCPERIOD(CLOSE) + ta = talib.HT_DCPERIOD(CLOSE) + assert len(ft) == len(ta) + + def test_nan_count_within_tolerance(self): + ft = ferro_ta.HT_DCPERIOD(CLOSE) + ta = talib.HT_DCPERIOD(CLOSE) + # ferro_ta uses 63-bar lookback; TA-Lib may use different warmup + assert abs(_nan_count(ft) - _nan_count(ta)) <= 35 + + def test_period_in_reasonable_range(self): + """Period should typically be in [6, 50] for realistic price data.""" + ft = ferro_ta.HT_DCPERIOD(CLOSE) + valid = ft[~np.isnan(ft)] + assert valid.min() > 0 + assert valid.max() <= 100.0 # allow some slack + + +class TestHTDCPhase: + """HT_DCPHASE — 63-bar lookback; shape parity enforced.""" + + def test_output_length_match(self): + ft = ferro_ta.HT_DCPHASE(CLOSE) + ta = talib.HT_DCPHASE(CLOSE) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.HT_DCPHASE(CLOSE) + ta = talib.HT_DCPHASE(CLOSE) + assert _nan_count(ft) == _nan_count(ta) + + def test_phase_sign_agreement(self): + """DC phase sign should agree with TA-Lib for some valid bars (Ehlers algo diff).""" + ft = ferro_ta.HT_DCPHASE(CLOSE) + ta = talib.HT_DCPHASE(CLOSE) + mask = _valid_mask(ft, ta) + if mask.sum() >= 5: + sign_agree = np.mean(np.sign(ft[mask]) == np.sign(ta[mask])) + # HT indicators use different warmup/accumulation vs TA-Lib + assert sign_agree >= 0.40, ( + f"HT_DCPHASE sign agreement {sign_agree:.2f} < 0.40" + ) + + +class TestHTPhasor: + """HT_PHASOR — 63-bar lookback; shape parity enforced. + + Returns (inphase, quadrature). Both components are correlated with TA-Lib. + """ + + def test_output_length_match(self): + ft_i, ft_q = ferro_ta.HT_PHASOR(CLOSE) + ta_i, ta_q = talib.HT_PHASOR(CLOSE) + assert len(ft_i) == len(ta_i) + assert len(ft_q) == len(ta_q) + + def test_nan_count_within_tolerance(self): + ft_i, ft_q = ferro_ta.HT_PHASOR(CLOSE) + ta_i, ta_q = talib.HT_PHASOR(CLOSE) + # ferro_ta uses 63-bar lookback; TA-Lib may use different warmup + assert abs(_nan_count(ft_i) - _nan_count(ta_i)) <= 35 + assert abs(_nan_count(ft_q) - _nan_count(ta_q)) <= 35 + + def test_inphase_sign_agreement(self): + """Inphase component sign should agree with TA-Lib for most valid bars.""" + ft_i, _ = ferro_ta.HT_PHASOR(CLOSE) + ta_i, _ = talib.HT_PHASOR(CLOSE) + mask = _valid_mask(ft_i, ta_i) + if mask.sum() >= 5: + sign_agree = np.mean(np.sign(ft_i[mask]) == np.sign(ta_i[mask])) + assert sign_agree >= SIGN_AGREEMENT_THRESHOLD + + +class TestHTSine: + """HT_SINE — 63-bar lookback; shape parity enforced. + + Returns (sine, leadsine). Values in [-1, 1]. + """ + + def test_output_length_match(self): + ft_s, ft_l = ferro_ta.HT_SINE(CLOSE) + ta_s, ta_l = talib.HT_SINE(CLOSE) + assert len(ft_s) == len(ta_s) + assert len(ft_l) == len(ta_l) + + def test_nan_count_match(self): + ft_s, ft_l = ferro_ta.HT_SINE(CLOSE) + ta_s, ta_l = talib.HT_SINE(CLOSE) + assert _nan_count(ft_s) == _nan_count(ta_s) + assert _nan_count(ft_l) == _nan_count(ta_l) + + def test_sine_range(self): + """Sine component should be in [-1.1, 1.1] (allow small numerical overshoot).""" + ft_s, _ = ferro_ta.HT_SINE(CLOSE) + valid = ft_s[~np.isnan(ft_s)] + assert valid.min() >= -1.1 + assert valid.max() <= 1.1 + + +class TestHTTrendMode: + """HT_TRENDMODE — 63-bar lookback; values are 0 or 1. + + Known difference: Boolean output derived from HT_DCPERIOD — may differ + from TA-Lib in first ~10 valid bars due to the same floating-point diff. + """ + + def test_output_length_match(self): + ft = ferro_ta.HT_TRENDMODE(CLOSE) + ta = talib.HT_TRENDMODE(CLOSE) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.HT_TRENDMODE(CLOSE) + ta = talib.HT_TRENDMODE(CLOSE) + assert _nan_count(ft) == _nan_count(ta) + + def test_binary_output(self): + """TRENDMODE values must be 0 or 1 (or NaN for warmup).""" + ft = ferro_ta.HT_TRENDMODE(CLOSE) + valid = ft[~np.isnan(ft)] + assert set(valid.astype(int)).issubset({0, 1}) + + def test_sign_agreement_with_talib(self): + """Trend mode should agree with TA-Lib for majority of valid bars. + + Note: HT_TRENDMODE is highly sensitive to Hilbert Transform phase + accumulator initialization; the two implementations use different + precision for the adaptive period, so agreement is ~54%. We verify + > 50% to confirm the indicator is better-than-random. + """ + ft = ferro_ta.HT_TRENDMODE(CLOSE) + ta = talib.HT_TRENDMODE(CLOSE) + mask = _valid_mask(ft, ta) + if mask.sum() >= 5: + agree = np.mean(ft[mask] == ta[mask]) + assert agree >= 0.50, f"HT_TRENDMODE agreement {agree:.2f} < 0.50" + + +# --------------------------------------------------------------------------- +# Candlestick Pattern Agreement Tests +# --------------------------------------------------------------------------- + + +# List of all candlestick patterns to test +ALL_CDL_PATTERNS = [ + "CDL2CROWS", + "CDL3BLACKCROWS", + "CDL3INSIDE", + "CDL3LINESTRIKE", + "CDL3OUTSIDE", + "CDL3STARSINSOUTH", + "CDL3WHITESOLDIERS", + "CDLABANDONEDBABY", + "CDLADVANCEBLOCK", + "CDLBELTHOLD", + "CDLBREAKAWAY", + "CDLCLOSINGMARUBOZU", + "CDLCONCEALBABYSWALL", + "CDLCOUNTERATTACK", + "CDLDARKCLOUDCOVER", + "CDLDOJI", + "CDLDOJISTAR", + "CDLDRAGONFLYDOJI", + "CDLENGULFING", + "CDLEVENINGDOJISTAR", + "CDLEVENINGSTAR", + "CDLGAPSIDESIDEWHITE", + "CDLGRAVESTONEDOJI", + "CDLHAMMER", + "CDLHANGINGMAN", + "CDLHARAMI", + "CDLHARAMICROSS", + "CDLHIGHWAVE", + "CDLHIKKAKE", + "CDLHIKKAKEMOD", + "CDLHOMINGPIGEON", + "CDLIDENTICAL3CROWS", + "CDLINNECK", + "CDLINVERTEDHAMMER", + "CDLKICKING", + "CDLKICKINGBYLENGTH", + "CDLLADDERBOTTOM", + "CDLLONGLEGGEDDOJI", + "CDLLONGLINE", + "CDLMARUBOZU", + "CDLMATCHINGLOW", + "CDLMATHOLD", + "CDLMORNINGDOJISTAR", + "CDLMORNINGSTAR", + "CDLONNECK", + "CDLPIERCING", + "CDLRICKSHAWMAN", + "CDLRISEFALL3METHODS", + "CDLSEPARATINGLINES", + "CDLSHOOTINGSTAR", + "CDLSHORTLINE", + "CDLSPINNINGTOP", + "CDLSTALLEDPATTERN", + "CDLSTICKSANDWICH", + "CDLTAKURI", + "CDLTASUKIGAP", + "CDLTHRUSTING", + "CDLTRISTAR", + "CDLUNIQUE3RIVER", + "CDLUPSIDEGAP2CROWS", + "CDLXSIDEGAP3METHODS", +] + + +class TestCandlestickPatternAgreement: + """Pattern recognition: agreement rate tests. + + Candlestick patterns may have slightly different threshold parameters + between implementations. We validate >80% agreement rate for pattern + detection (non-zero output). + """ + + @pytest.mark.parametrize("pattern_name", ALL_CDL_PATTERNS) + def test_pattern_agreement_rate(self, pattern_name): + """Test that pattern agreement rate is > 80%.""" + # Get pattern functions + ft_func = getattr(ferro_ta, pattern_name, None) + ta_func = getattr(talib, pattern_name, None) + + if ft_func is None: + pytest.skip(f"ferro_ta.{pattern_name} not implemented") + if ta_func is None: + pytest.skip(f"talib.{pattern_name} not available") + + # Compute patterns + ft = ft_func(OPEN, HIGH, LOW, CLOSE) + ta = ta_func(OPEN, HIGH, LOW, CLOSE) + + # Check output length match + assert len(ft) == len(ta), f"{pattern_name}: length mismatch" + + # Compute agreement rate (exact match of output values) + # Patterns typically return 0, ±100, or ±200 + agreement = np.mean(ft == ta) + + # Use per-pattern threshold (some patterns have known definition differences) + threshold = CDL_AGREEMENT_THRESHOLDS.get(pattern_name, 0.80) + assert agreement > threshold, ( + f"{pattern_name}: agreement rate {agreement:.2%} < {threshold:.0%}" + ) + + def test_pattern_sample_doji(self): + """Spot check: CDLDOJI should have high agreement (known: shadow ratio precision differs).""" + ft = ferro_ta.CDLDOJI(OPEN, HIGH, LOW, CLOSE) + ta = talib.CDLDOJI(OPEN, HIGH, LOW, CLOSE) + + agreement = np.mean(ft == ta) + # ferro_ta uses slightly different shadow/body ratio threshold; 86% observed + assert agreement > 0.85 + + def test_pattern_sample_engulfing(self): + """Spot check: CDLENGULFING should have high agreement.""" + ft = ferro_ta.CDLENGULFING(OPEN, HIGH, LOW, CLOSE) + ta = talib.CDLENGULFING(OPEN, HIGH, LOW, CLOSE) + + agreement = np.mean(ft == ta) + assert agreement > 0.80 + + def test_pattern_sample_hammer(self): + """Spot check: CDLHAMMER should have high agreement.""" + ft = ferro_ta.CDLHAMMER(OPEN, HIGH, LOW, CLOSE) + ta = talib.CDLHAMMER(OPEN, HIGH, LOW, CLOSE) + + agreement = np.mean(ft == ta) + assert agreement > 0.80 diff --git a/ferro-ta-main/tests/integration/test_wasm_node_conformance.py b/ferro-ta-main/tests/integration/test_wasm_node_conformance.py new file mode 100644 index 0000000..1f0f48b --- /dev/null +++ b/ferro-ta-main/tests/integration/test_wasm_node_conformance.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +import ferro_ta + +ROOT = Path(__file__).resolve().parents[2] +WASM_DIR = ROOT / "wasm" +PKG_JS = WASM_DIR / "pkg" / "ferro_ta_wasm.js" +SCRIPT = WASM_DIR / "conformance_node.js" + + +def _write_node_conformance_script(path: Path) -> None: + path.write_text( + """ +const wasm = require("./node/ferro_ta_wasm.js"); + +function toArray(x) { + return Array.from(x, (v) => (Number.isNaN(v) ? null : Number(v))); +} + +const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.1, 45.42, 45.84, 46.08, 45.89, 46.03, 46.21, 46.02, 45.78]); +const high = new Float64Array([44.71, 44.5, 44.6, 44.09, 44.79, 45.2, 45.44, 45.73, 46.01, 46.44, 46.21, 46.39, 46.53, 46.3, 46.12]); +const low = new Float64Array([43.9, 43.8, 43.9, 43.2, 43.9, 44.2, 44.6, 44.8, 45.2, 45.5, 45.4, 45.5, 45.7, 45.6, 45.4]); +const volume = new Float64Array([1200, 1320, 1250, 1460, 1500, 1670, 1720, 1810, 1900, 2020, 1980, 2100, 2170, 2140, 2080]); + +const payload = { + sma: toArray(wasm.sma(close, 5)), + ema: toArray(wasm.ema(close, 5)), + wma: toArray(wasm.wma(close, 5)), + rsi: toArray(wasm.rsi(close, 5)), + adx: toArray(wasm.adx(high, low, close, 5)), + mfi: toArray(wasm.mfi(high, low, close, volume, 5)), +}; + +process.stdout.write(JSON.stringify(payload)); +""".strip() + + "\n", + encoding="utf-8", + ) + + +def _run_node_conformance() -> dict[str, list[float | None]]: + if shutil.which("node") is None: + pytest.skip("node is required for wasm/node conformance test") + if not PKG_JS.exists(): + pytest.skip( + "wasm/pkg not found; run `wasm-pack build --target nodejs --out-dir pkg`" + ) + + _write_node_conformance_script(SCRIPT) + try: + out = subprocess.check_output( + ["node", str(SCRIPT)], + cwd=WASM_DIR, + text=True, + ) + finally: + if SCRIPT.exists(): + SCRIPT.unlink() + return json.loads(out) + + +def _to_jsonable(arr: np.ndarray) -> list[float | None]: + vals = np.asarray(arr, dtype=np.float64) + return [None if np.isnan(x) else float(x) for x in vals] + + +def _assert_close_with_null_nan( + actual: list[float | None], + expected: list[float | None], + *, + atol: float, +) -> None: + assert len(actual) == len(expected) + a = np.array([np.nan if v is None else float(v) for v in actual], dtype=np.float64) + e = np.array( + [np.nan if v is None else float(v) for v in expected], dtype=np.float64 + ) + np.testing.assert_allclose(a, e, atol=atol, rtol=0.0, equal_nan=True) + + +def test_wasm_node_matches_python_core_indicators() -> None: + close = np.array( + [ + 44.34, + 44.09, + 44.15, + 43.61, + 44.33, + 44.83, + 45.10, + 45.42, + 45.84, + 46.08, + 45.89, + 46.03, + 46.21, + 46.02, + 45.78, + ], + dtype=np.float64, + ) + high = np.array( + [ + 44.71, + 44.50, + 44.60, + 44.09, + 44.79, + 45.20, + 45.44, + 45.73, + 46.01, + 46.44, + 46.21, + 46.39, + 46.53, + 46.30, + 46.12, + ], + dtype=np.float64, + ) + low = np.array( + [ + 43.90, + 43.80, + 43.90, + 43.20, + 43.90, + 44.20, + 44.60, + 44.80, + 45.20, + 45.50, + 45.40, + 45.50, + 45.70, + 45.60, + 45.40, + ], + dtype=np.float64, + ) + volume = np.array( + [ + 1200.0, + 1320.0, + 1250.0, + 1460.0, + 1500.0, + 1670.0, + 1720.0, + 1810.0, + 1900.0, + 2020.0, + 1980.0, + 2100.0, + 2170.0, + 2140.0, + 2080.0, + ], + dtype=np.float64, + ) + + node_payload = _run_node_conformance() + + py_expected = { + "sma": _to_jsonable(ferro_ta.SMA(close, 5)), + "ema": _to_jsonable(ferro_ta.EMA(close, 5)), + "wma": _to_jsonable(ferro_ta.WMA(close, 5)), + "rsi": _to_jsonable(ferro_ta.RSI(close, 5)), + "adx": _to_jsonable(ferro_ta.ADX(high, low, close, 5)), + "mfi": _to_jsonable(ferro_ta.MFI(high, low, close, volume, 5)), + } + + for name, expected in py_expected.items(): + assert name in node_payload + _assert_close_with_null_nan(node_payload[name], expected, atol=1e-9) diff --git a/ferro-ta-main/tests/unit/analysis/__init__.py b/ferro-ta-main/tests/unit/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ferro-ta-main/tests/unit/analysis/test_backtest_advanced.py b/ferro-ta-main/tests/unit/analysis/test_backtest_advanced.py new file mode 100644 index 0000000..9b5229f --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/tests/unit/analysis/test_backtest_v2.py b/ferro-ta-main/tests/unit/analysis/test_backtest_v2.py new file mode 100644 index 0000000..440da41 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/tests/unit/conftest.py b/ferro-ta-main/tests/unit/conftest.py new file mode 100644 index 0000000..a0de85d --- /dev/null +++ b/ferro-ta-main/tests/unit/conftest.py @@ -0,0 +1,7 @@ +""" +Unit test conftest — inherits shared fixtures from tests/conftest.py. + +pytest automatically loads parent conftest.py files, so all fixtures +defined in tests/conftest.py (ohlcv_500, ohlcv_100, ohlcv_real) are +available here without any explicit import. +""" diff --git a/ferro-ta-main/tests/unit/helpers.py b/ferro-ta-main/tests/unit/helpers.py new file mode 100644 index 0000000..10b06e6 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/tests/unit/indicators/__init__.py b/ferro-ta-main/tests/unit/indicators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ferro-ta-main/tests/unit/indicators/test_cycle.py b/ferro-ta-main/tests/unit/indicators/test_cycle.py new file mode 100644 index 0000000..bea645d --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_cycle.py @@ -0,0 +1,183 @@ +"""Unit tests for ferro_ta.indicators.cycle""" + +import numpy as np + +from ferro_ta.indicators.cycle import ( + HT_DCPERIOD, + HT_DCPHASE, + HT_PHASOR, + HT_SINE, + HT_TRENDLINE, + HT_TRENDMODE, +) + +# --------------------------------------------------------------------------- +# Shared fixtures — cycle indicators need at least ~64 bars for valid output +# --------------------------------------------------------------------------- + +N = 200 +t = np.linspace(0, 10 * np.pi, N) +SINE_CLOSE = 100 + 10 * np.sin(t) # clean sine wave + + +def _warmup_end(arr): + """Return index of first non-NaN value (or N if all NaN).""" + valid = np.where(~np.isnan(arr.astype(float)))[0] + return valid[0] if len(valid) else N + + +# --------------------------------------------------------------------------- +# HT_DCPERIOD +# --------------------------------------------------------------------------- + + +class TestHT_DCPERIOD: + def test_length(self): + result = HT_DCPERIOD(SINE_CLOSE) + assert len(result) == N + + def test_nan_warmup(self): + result = HT_DCPERIOD(SINE_CLOSE) + w = _warmup_end(result) + assert w > 0 + assert np.all(np.isnan(result[:w])) + + def test_valid_finite(self): + result = HT_DCPERIOD(SINE_CLOSE) + w = _warmup_end(result) + assert np.all(np.isfinite(result[w:])) + + def test_sine_period_reasonable(self): + # Our sine has period = 2*pi in t; with N=200 and t in [0,10*pi] + # the true period in samples = 200 / (10*pi / (2*pi)) = 200/5 = 40 + result = HT_DCPERIOD(SINE_CLOSE) + valid = result[~np.isnan(result)] + # HT_DCPERIOD should detect a period in a reasonable range [6, 100] + assert np.any((valid > 6) & (valid < 100)) + + +# --------------------------------------------------------------------------- +# HT_DCPHASE +# --------------------------------------------------------------------------- + + +class TestHT_DCPHASE: + def test_length(self): + assert len(HT_DCPHASE(SINE_CLOSE)) == N + + def test_nan_warmup(self): + result = HT_DCPHASE(SINE_CLOSE) + w = _warmup_end(result) + assert w > 0 + + def test_valid_finite(self): + result = HT_DCPHASE(SINE_CLOSE) + w = _warmup_end(result) + assert np.all(np.isfinite(result[w:])) + + +# --------------------------------------------------------------------------- +# HT_PHASOR +# --------------------------------------------------------------------------- + + +class TestHT_PHASOR: + def test_returns_two_arrays(self): + result = HT_PHASOR(SINE_CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + inphase, quadrature = HT_PHASOR(SINE_CLOSE) + assert len(inphase) == len(quadrature) == N + + def test_nan_warmup(self): + inphase, quadrature = HT_PHASOR(SINE_CLOSE) + w = _warmup_end(inphase) + assert w > 0 + + def test_valid_finite(self): + inphase, quadrature = HT_PHASOR(SINE_CLOSE) + wi = _warmup_end(inphase) + wq = _warmup_end(quadrature) + assert np.all(np.isfinite(inphase[wi:])) + assert np.all(np.isfinite(quadrature[wq:])) + + +# --------------------------------------------------------------------------- +# HT_SINE +# --------------------------------------------------------------------------- + + +class TestHT_SINE: + def test_returns_two_arrays(self): + result = HT_SINE(SINE_CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + sine, leadsine = HT_SINE(SINE_CLOSE) + assert len(sine) == len(leadsine) == N + + def test_nan_warmup(self): + sine, leadsine = HT_SINE(SINE_CLOSE) + w = _warmup_end(sine) + assert w > 0 + + def test_valid_finite(self): + sine, leadsine = HT_SINE(SINE_CLOSE) + ws = _warmup_end(sine) + wl = _warmup_end(leadsine) + assert np.all(np.isfinite(sine[ws:])) + assert np.all(np.isfinite(leadsine[wl:])) + + def test_values_in_sine_range(self): + # Sine values should be in [-1, 1] roughly + sine, leadsine = HT_SINE(SINE_CLOSE) + valid = sine[~np.isnan(sine)] + assert np.all(valid >= -1.5) and np.all(valid <= 1.5) + + +# --------------------------------------------------------------------------- +# HT_TRENDLINE +# --------------------------------------------------------------------------- + + +class TestHT_TRENDLINE: + def test_length(self): + assert len(HT_TRENDLINE(SINE_CLOSE)) == N + + def test_nan_warmup(self): + result = HT_TRENDLINE(SINE_CLOSE) + w = _warmup_end(result) + assert w > 0 + + def test_valid_finite(self): + result = HT_TRENDLINE(SINE_CLOSE) + w = _warmup_end(result) + assert np.all(np.isfinite(result[w:])) + + def test_smooth_trendline(self): + # Trendline should be smoother than raw close + result = HT_TRENDLINE(SINE_CLOSE) + w = _warmup_end(result) + raw_std = np.std(np.diff(SINE_CLOSE[w:])) + trend_std = np.std(np.diff(result[w:])) + assert trend_std < raw_std + + +# --------------------------------------------------------------------------- +# HT_TRENDMODE +# --------------------------------------------------------------------------- + + +class TestHT_TRENDMODE: + def test_length(self): + assert len(HT_TRENDMODE(SINE_CLOSE)) == N + + def test_values_binary(self): + result = HT_TRENDMODE(SINE_CLOSE) + assert np.all(np.isin(result, [0, 1])) + + def test_nan_warmup_as_zero(self): + # HT_TRENDMODE returns integers (no NaN); warmup bars should be 0 + result = HT_TRENDMODE(SINE_CLOSE) + assert np.all(np.isfinite(result.astype(float))) diff --git a/ferro-ta-main/tests/unit/indicators/test_extended.py b/ferro-ta-main/tests/unit/indicators/test_extended.py new file mode 100644 index 0000000..f849a01 --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_extended.py @@ -0,0 +1,292 @@ +"""Unit tests for ferro_ta.indicators.extended""" + +import numpy as np + +from ferro_ta.indicators.extended import ( + CHANDELIER_EXIT, + CHOPPINESS_INDEX, + DONCHIAN, + HULL_MA, + ICHIMOKU, + KELTNER_CHANNELS, + PIVOT_POINTS, + SUPERTREND, + VWAP, + VWMA, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(99) +N = 200 +_C = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_H = _C + np.abs(RNG.normal(0, 0.3, N)) +_L = _C - np.abs(RNG.normal(0, 0.3, N)) +_O = _C + RNG.normal(0, 0.1, N) +_VOL = RNG.uniform(1000, 5000, N) + + +# --------------------------------------------------------------------------- +# VWAP +# --------------------------------------------------------------------------- + + +class TestVWAP: + def test_length(self): + result = VWAP(_H, _L, _C, _VOL) + assert len(result) == N + + def test_no_nan(self): + result = VWAP(_H, _L, _C, _VOL) + assert np.all(np.isfinite(result)) + + def test_positive(self): + result = VWAP(_H, _L, _C, _VOL) + assert np.all(result > 0) + + def test_windowed(self): + result = VWAP(_H, _L, _C, _VOL, timeperiod=20) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# SUPERTREND +# --------------------------------------------------------------------------- + + +class TestSUPERTREND: + def test_returns_two_arrays(self): + result = SUPERTREND(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + trend, direction = SUPERTREND(_H, _L, _C) + assert len(trend) == len(direction) == N + + def test_direction_binary(self): + trend, direction = SUPERTREND(_H, _L, _C) + valid = direction[~np.isnan(direction.astype(float))] + assert np.all(np.isin(valid, [-1, 0, 1])) + + def test_nan_warmup(self): + trend, direction = SUPERTREND(_H, _L, _C, timeperiod=7) + assert np.any(np.isnan(trend)) + + +# --------------------------------------------------------------------------- +# ICHIMOKU +# --------------------------------------------------------------------------- + + +class TestICHIMOKU: + def test_returns_five_arrays(self): + result = ICHIMOKU(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 5 + + def test_length(self): + result = ICHIMOKU(_H, _L, _C) + for arr in result: + assert len(arr) == N + + def test_tenkan_warmup(self): + tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU( + _H, _L, _C, tenkan_period=9 + ) + assert np.all(np.isnan(tenkan[:8])) + + def test_finite_after_warmup(self): + tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(_H, _L, _C) + for arr in [tenkan, kijun]: + valid = arr[~np.isnan(arr)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# DONCHIAN +# --------------------------------------------------------------------------- + + +class TestDONCHIAN: + def test_returns_three_arrays(self): + result = DONCHIAN(_H, _L) + assert isinstance(result, tuple) and len(result) == 3 + + def test_length(self): + upper, middle, lower = DONCHIAN(_H, _L) + assert len(upper) == len(middle) == len(lower) == N + + def test_upper_ge_lower(self): + upper, middle, lower = DONCHIAN(_H, _L) + valid = ~np.isnan(upper) & ~np.isnan(lower) + assert np.all(upper[valid] >= lower[valid]) + + def test_middle_is_average(self): + upper, middle, lower = DONCHIAN(_H, _L) + valid = ~np.isnan(upper) & ~np.isnan(lower) & ~np.isnan(middle) + np.testing.assert_allclose( + middle[valid], + (upper[valid] + lower[valid]) / 2.0, + rtol=1e-10, + ) + + def test_nan_warmup(self): + upper, middle, lower = DONCHIAN(_H, _L, timeperiod=20) + assert np.all(np.isnan(upper[:19])) + + +# --------------------------------------------------------------------------- +# PIVOT_POINTS +# --------------------------------------------------------------------------- + + +class TestPIVOT_POINTS: + def test_returns_five_arrays(self): + result = PIVOT_POINTS(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 5 + + def test_length(self): + result = PIVOT_POINTS(_H, _L, _C) + for arr in result: + assert len(arr) == N + + def test_classic_pivot_formula(self): + # PP = (H + L + C) / 3 + pp, r1, s1, r2, s2 = PIVOT_POINTS(_H, _L, _C, method="classic") + valid = ~np.isnan(pp) + expected_pp = (_H[:-1] + _L[:-1] + _C[:-1]) / 3.0 + np.testing.assert_allclose(pp[valid], expected_pp[valid[1:]], rtol=1e-6) + + def test_first_is_nan(self): + pp, r1, s1, r2, s2 = PIVOT_POINTS(_H, _L, _C) + assert np.isnan(pp[0]) + + +# --------------------------------------------------------------------------- +# KELTNER_CHANNELS +# --------------------------------------------------------------------------- + + +class TestKELTNER_CHANNELS: + def test_returns_three_arrays(self): + result = KELTNER_CHANNELS(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 3 + + def test_length(self): + upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C) + assert len(upper) == len(middle) == len(lower) == N + + def test_upper_gt_lower(self): + upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C) + valid = ~np.isnan(upper) & ~np.isnan(lower) + assert np.all(upper[valid] > lower[valid]) + + def test_nan_warmup(self): + upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C, timeperiod=20) + assert np.all(np.isnan(upper[:19])) + + +# --------------------------------------------------------------------------- +# HULL_MA +# --------------------------------------------------------------------------- + + +class TestHULL_MA: + def test_length(self): + assert len(HULL_MA(_C, timeperiod=16)) == N + + def test_nan_warmup(self): + result = HULL_MA(_C, timeperiod=16) + assert np.all(np.isnan(result[:18])) + + def test_finite_after_warmup(self): + result = HULL_MA(_C, timeperiod=16) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_tracks_trend(self): + rising = np.linspace(10.0, 200.0, 200) + result = HULL_MA(rising, timeperiod=16) + valid = result[~np.isnan(result)] + assert np.all(np.diff(valid) > 0) + + +# --------------------------------------------------------------------------- +# CHANDELIER_EXIT +# --------------------------------------------------------------------------- + + +class TestCHANDELIER_EXIT: + def test_returns_two_arrays(self): + result = CHANDELIER_EXIT(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C) + assert len(long_stop) == len(short_stop) == N + + def test_nan_warmup(self): + long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C, timeperiod=22) + assert np.all(np.isnan(long_stop[:21])) + + def test_finite_after_warmup(self): + long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C, timeperiod=22) + for arr in [long_stop, short_stop]: + valid = arr[~np.isnan(arr)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# VWMA +# --------------------------------------------------------------------------- + + +class TestVWMA: + def test_length(self): + assert len(VWMA(_C, _VOL, timeperiod=20)) == N + + def test_nan_warmup(self): + result = VWMA(_C, _VOL, timeperiod=20) + assert np.all(np.isnan(result[:19])) + + def test_finite_after_warmup(self): + result = VWMA(_C, _VOL, timeperiod=20) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_constant_volume_equals_sma(self): + # When all volumes are equal, VWMA = SMA + vol = np.ones(N) * 1000.0 + vwma = VWMA(_C, vol, timeperiod=20) + from ferro_ta.indicators.overlap import SMA + + sma = SMA(_C, timeperiod=20) + valid = ~np.isnan(vwma) & ~np.isnan(sma) + np.testing.assert_allclose(vwma[valid], sma[valid], rtol=1e-8) + + +# --------------------------------------------------------------------------- +# CHOPPINESS_INDEX +# --------------------------------------------------------------------------- + + +class TestCHOPPINESS_INDEX: + def test_length(self): + assert len(CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)) == N + + def test_nan_warmup(self): + result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14) + assert np.all(np.isnan(result[:14])) + + def test_range(self): + # Choppiness index is bounded between 0 and 100 + result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14) + valid = result[~np.isnan(result)] + assert np.all(valid > 0) and np.all(valid < 200) + + def test_finite_after_warmup(self): + result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) diff --git a/ferro-ta-main/tests/unit/indicators/test_math_ops.py b/ferro-ta-main/tests/unit/indicators/test_math_ops.py new file mode 100644 index 0000000..a65f56c --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_math_ops.py @@ -0,0 +1,313 @@ +"""Unit tests for ferro_ta.indicators.math_ops""" + +import numpy as np + +from ferro_ta.indicators.math_ops import ( + ACOS, + ADD, + ASIN, + ATAN, + CEIL, + COS, + COSH, + DIV, + EXP, + FLOOR, + LN, + LOG10, + MAX, + MAXINDEX, + MIN, + MININDEX, + MULT, + SIN, + SINH, + SQRT, + SUB, + SUM, + TAN, + TANH, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +A3 = np.array([1.0, 2.0, 3.0]) +B3 = np.array([4.0, 5.0, 6.0]) +TRIG = np.array([0.0, np.pi / 6, np.pi / 4, np.pi / 3, np.pi / 2]) +UNIT = np.array([0.0, 0.25, 0.5, 0.75, 1.0]) # values in [0,1] for ASIN/ACOS + +RNG = np.random.default_rng(17) +N = 100 +_ARR = 1.0 + RNG.random(N) * 9.0 # positive values in (1, 10] + + +# --------------------------------------------------------------------------- +# ADD +# --------------------------------------------------------------------------- + + +class TestADD: + def test_known_values(self): + result = ADD(A3, B3) + np.testing.assert_allclose(result, [5.0, 7.0, 9.0], rtol=1e-10) + + def test_commutative(self): + np.testing.assert_allclose(ADD(A3, B3), ADD(B3, A3), rtol=1e-10) + + def test_length(self): + assert len(ADD(_ARR, _ARR)) == N + + +# --------------------------------------------------------------------------- +# SUB +# --------------------------------------------------------------------------- + + +class TestSUB: + def test_known_values(self): + result = SUB(B3, A3) + np.testing.assert_allclose(result, [3.0, 3.0, 3.0], rtol=1e-10) + + def test_length(self): + assert len(SUB(_ARR, _ARR)) == N + + +# --------------------------------------------------------------------------- +# MULT +# --------------------------------------------------------------------------- + + +class TestMULT: + def test_known_values(self): + result = MULT(A3, B3) + np.testing.assert_allclose(result, [4.0, 10.0, 18.0], rtol=1e-10) + + def test_commutative(self): + np.testing.assert_allclose(MULT(A3, B3), MULT(B3, A3), rtol=1e-10) + + def test_length(self): + assert len(MULT(_ARR, _ARR)) == N + + +# --------------------------------------------------------------------------- +# DIV +# --------------------------------------------------------------------------- + + +class TestDIV: + def test_known_values(self): + result = DIV(B3, A3) + np.testing.assert_allclose(result, [4.0, 2.5, 2.0], rtol=1e-10) + + def test_self_division_is_one(self): + np.testing.assert_allclose(DIV(_ARR, _ARR), np.ones(N), rtol=1e-10) + + def test_length(self): + assert len(DIV(_ARR, _ARR)) == N + + +# --------------------------------------------------------------------------- +# SUM +# --------------------------------------------------------------------------- + + +class TestSUM: + def test_known_values(self): + arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = SUM(arr, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 6.0, rtol=1e-10) + np.testing.assert_allclose(result[4], 12.0, rtol=1e-10) + + def test_nan_warmup(self): + result = SUM(_ARR, timeperiod=5) + assert np.all(np.isnan(result[:4])) + + def test_length(self): + assert len(SUM(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# MAX +# --------------------------------------------------------------------------- + + +class TestMAX: + def test_known_values(self): + arr = np.array([1.0, 3.0, 2.0, 5.0, 4.0]) + result = MAX(arr, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 3.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 5.0, rtol=1e-10) + np.testing.assert_allclose(result[4], 5.0, rtol=1e-10) + + def test_nan_warmup(self): + result = MAX(_ARR, timeperiod=5) + assert np.all(np.isnan(result[:4])) + + def test_length(self): + assert len(MAX(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# MIN +# --------------------------------------------------------------------------- + + +class TestMIN: + def test_known_values(self): + arr = np.array([5.0, 3.0, 4.0, 1.0, 2.0]) + result = MIN(arr, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 3.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 1.0, rtol=1e-10) + + def test_length(self): + assert len(MIN(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# MAXINDEX +# --------------------------------------------------------------------------- + + +class TestMAXINDEX: + def test_known_values(self): + arr = np.array([1.0, 5.0, 3.0, 2.0, 4.0]) + result = MAXINDEX(arr, timeperiod=3) + # warmup entries are -1 (sentinel for "no data") + assert result[0] < 0 and result[1] < 0 + # window[0:3] = [1,5,3] → max at local index 1 → absolute index 1 + np.testing.assert_allclose(result[2], 1.0, rtol=1e-10) + # window[2:5] = [3,2,4] → max at local index 2 → absolute index 4 + np.testing.assert_allclose(result[4], 4.0, rtol=1e-10) + + def test_length(self): + assert len(MAXINDEX(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# MININDEX +# --------------------------------------------------------------------------- + + +class TestMININDEX: + def test_known_values(self): + arr = np.array([5.0, 1.0, 3.0, 2.0, 4.0]) + result = MININDEX(arr, timeperiod=3) + # warmup entries are -1 (sentinel for "no data") + assert result[0] < 0 and result[1] < 0 + # window[0:3] = [5,1,3] → min at local index 1 → absolute index 1 + np.testing.assert_allclose(result[2], 1.0, rtol=1e-10) + # window[2:5] = [3,2,4] → min at local index 1 → absolute index 3 + np.testing.assert_allclose(result[4], 3.0, rtol=1e-10) + + def test_length(self): + assert len(MININDEX(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# Trig functions +# --------------------------------------------------------------------------- + + +class TestSIN: + def test_known_values(self): + angles = np.array([0.0, np.pi / 2, np.pi]) + result = SIN(angles) + np.testing.assert_allclose(result, np.sin(angles), atol=1e-10) + + def test_matches_numpy(self): + np.testing.assert_allclose(SIN(TRIG), np.sin(TRIG), rtol=1e-10) + + +class TestCOS: + def test_matches_numpy(self): + np.testing.assert_allclose(COS(TRIG), np.cos(TRIG), rtol=1e-10) + + +class TestTAN: + def test_matches_numpy(self): + safe = np.array([0.0, 0.5, 1.0]) + np.testing.assert_allclose(TAN(safe), np.tan(safe), rtol=1e-10) + + +class TestASIN: + def test_matches_numpy(self): + np.testing.assert_allclose(ASIN(UNIT), np.arcsin(UNIT), rtol=1e-10) + + +class TestACOS: + def test_matches_numpy(self): + np.testing.assert_allclose(ACOS(UNIT), np.arccos(UNIT), rtol=1e-10) + + +class TestATAN: + def test_matches_numpy(self): + np.testing.assert_allclose(ATAN(TRIG), np.arctan(TRIG), rtol=1e-10) + + +class TestSINH: + def test_matches_numpy(self): + np.testing.assert_allclose(SINH(A3), np.sinh(A3), rtol=1e-10) + + +class TestCOSH: + def test_matches_numpy(self): + np.testing.assert_allclose(COSH(A3), np.cosh(A3), rtol=1e-10) + + +class TestTANH: + def test_matches_numpy(self): + np.testing.assert_allclose(TANH(UNIT), np.tanh(UNIT), rtol=1e-10) + + +# --------------------------------------------------------------------------- +# Rounding/exponential +# --------------------------------------------------------------------------- + + +class TestCEIL: + def test_known_values(self): + arr = np.array([1.1, 2.5, 3.9, -0.5]) + np.testing.assert_allclose(CEIL(arr), np.ceil(arr), rtol=1e-10) + + +class TestFLOOR: + def test_known_values(self): + arr = np.array([1.1, 2.5, 3.9, -0.5]) + np.testing.assert_allclose(FLOOR(arr), np.floor(arr), rtol=1e-10) + + +class TestEXP: + def test_matches_numpy(self): + np.testing.assert_allclose(EXP(A3), np.exp(A3), rtol=1e-10) + + def test_exp_zero_is_one(self): + np.testing.assert_allclose(EXP(np.array([0.0])), [1.0], rtol=1e-10) + + +class TestLN: + def test_matches_numpy(self): + np.testing.assert_allclose(LN(_ARR), np.log(_ARR), rtol=1e-10) + + def test_ln_exp_inverse(self): + np.testing.assert_allclose(LN(EXP(A3)), A3, rtol=1e-10) + + +class TestLOG10: + def test_matches_numpy(self): + np.testing.assert_allclose(LOG10(_ARR), np.log10(_ARR), rtol=1e-10) + + def test_log10_of_100_is_2(self): + np.testing.assert_allclose(LOG10(np.array([100.0])), [2.0], rtol=1e-10) + + +class TestSQRT: + def test_matches_numpy(self): + np.testing.assert_allclose(SQRT(_ARR), np.sqrt(_ARR), rtol=1e-10) + + def test_sqrt_of_4_is_2(self): + np.testing.assert_allclose(SQRT(np.array([4.0])), [2.0], rtol=1e-10) diff --git a/ferro-ta-main/tests/unit/indicators/test_momentum.py b/ferro-ta-main/tests/unit/indicators/test_momentum.py new file mode 100644 index 0000000..377c300 --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_momentum.py @@ -0,0 +1,588 @@ +"""Unit tests for ferro_ta.indicators.momentum""" + +import numpy as np + +from ferro_ta.indicators.momentum import ( + ADX, + ADXR, + APO, + AROON, + AROONOSC, + BOP, + CCI, + CMO, + DX, + MFI, + MINUS_DI, + MINUS_DM, + MOM, + PLUS_DI, + PLUS_DM, + PPO, + ROC, + ROCP, + ROCR, + ROCR100, + RSI, + STOCH, + STOCHF, + STOCHRSI, + TRIX, + ULTOSC, + WILLR, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(7) +N = 100 +_CLOSE = 100 + 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) +_VOL = RNG.uniform(1000, 5000, N) + +SMALL5 = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) +SMALL5_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) +SMALL5_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) +SMALL5_O = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) +SMALL5_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0]) + + +# --------------------------------------------------------------------------- +# RSI +# --------------------------------------------------------------------------- + + +class TestRSI: + def test_nan_warmup(self): + result = RSI(_CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:14])) + + def test_range(self): + result = RSI(_CLOSE, timeperiod=14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(RSI(_CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# STOCH +# --------------------------------------------------------------------------- + + +class TestSTOCH: + def test_returns_two_arrays(self): + result = STOCH(_HIGH, _LOW, _CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_range(self): + slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE) + for arr in [slowk, slowd]: + valid = arr[~np.isnan(arr)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE) + assert len(slowk) == len(slowd) == N + + +# --------------------------------------------------------------------------- +# STOCHF +# --------------------------------------------------------------------------- + + +class TestSTOCHF: + def test_returns_two_arrays(self): + result = STOCHF(_HIGH, _LOW, _CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_fastk_range(self): + fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE, fastk_period=5, fastd_period=3) + valid = fastk[~np.isnan(fastk)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_known_values(self): + # With identical OHLC, fast %K = 100 * (C - min_low) / (max_high - min_low) + # On our SMALL5 data the range is constant so all = 2/6 * 100 ≈ 66.67 + h5 = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) + l5 = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) + c5 = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) + fastk, fastd = STOCHF(h5, l5, c5, fastk_period=3, fastd_period=2) + valid_k = fastk[~np.isnan(fastk)] + assert np.all(valid_k >= 0) and np.all(valid_k <= 100) + + def test_length(self): + fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE) + assert len(fastk) == len(fastd) == N + + +# --------------------------------------------------------------------------- +# STOCHRSI +# --------------------------------------------------------------------------- + + +class TestSTOCHRSI: + def test_returns_two_arrays(self): + result = STOCHRSI(_CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_range(self): + fastk, fastd = STOCHRSI(_CLOSE, timeperiod=14) + for arr in [fastk, fastd]: + valid = arr[~np.isnan(arr)] + assert np.all(valid >= -1e-10) and np.all(valid <= 100 + 1e-10) + + def test_length(self): + fastk, fastd = STOCHRSI(_CLOSE) + assert len(fastk) == N + + +# --------------------------------------------------------------------------- +# ADX +# --------------------------------------------------------------------------- + + +class TestADX: + def test_nan_warmup(self): + result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:27])) + + def test_range(self): + result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(ADX(_HIGH, _LOW, _CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# ADXR +# --------------------------------------------------------------------------- + + +class TestADXR: + def test_length(self): + assert len(ADXR(_HIGH, _LOW, _CLOSE, 14)) == N + + def test_range(self): + result = ADXR(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + +# --------------------------------------------------------------------------- +# CCI +# --------------------------------------------------------------------------- + + +class TestCCI: + def test_known_constant_mean_dev(self): + # Constant typical price → CCI = 0 after warmup + c5 = np.full(10, 12.0) + h5 = np.full(10, 13.0) + l5 = np.full(10, 11.0) + result = CCI(h5, l5, c5, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-10) + + def test_length(self): + assert len(CCI(_HIGH, _LOW, _CLOSE, 14)) == N + + def test_nan_warmup(self): + result = CCI(_HIGH, _LOW, _CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_simple_rising(self): + h = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) + l = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) + c = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) + result = CCI(h, l, c, 3) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 100.0, atol=1e-8) + + +# --------------------------------------------------------------------------- +# WILLR +# --------------------------------------------------------------------------- + + +class TestWILLR: + def test_range(self): + result = WILLR(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= -100) and np.all(valid <= 0) + + def test_length(self): + assert len(WILLR(_HIGH, _LOW, _CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# AROON +# --------------------------------------------------------------------------- + + +class TestAROON: + def test_returns_two_arrays(self): + result = AROON(_HIGH, _LOW, 14) + assert isinstance(result, tuple) and len(result) == 2 + + def test_range(self): + aroon_down, aroon_up = AROON(_HIGH, _LOW, 14) + for arr in [aroon_down, aroon_up]: + valid = arr[~np.isnan(arr)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + aroon_down, aroon_up = AROON(_HIGH, _LOW, 14) + assert len(aroon_down) == N + + +# --------------------------------------------------------------------------- +# AROONOSC +# --------------------------------------------------------------------------- + + +class TestAROONOSC: + def test_known_values(self): + h = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) + l = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) + result = AROONOSC(h, l, timeperiod=2) + valid = result[~np.isnan(result)] + # Monotone rising high/low → aroon_up = 100, aroon_down = 0 → osc = 100 + np.testing.assert_allclose(valid, 100.0, atol=1e-10) + + def test_equals_aroon_diff(self): + aroon_down, aroon_up = AROON(_HIGH, _LOW, 14) + aroonosc = AROONOSC(_HIGH, _LOW, 14) + valid = ~np.isnan(aroon_up) & ~np.isnan(aroon_down) & ~np.isnan(aroonosc) + np.testing.assert_allclose( + aroonosc[valid], + aroon_up[valid] - aroon_down[valid], + atol=1e-10, + ) + + def test_length(self): + assert len(AROONOSC(_HIGH, _LOW, 14)) == N + + +# --------------------------------------------------------------------------- +# MFI +# --------------------------------------------------------------------------- + + +class TestMFI: + def test_range(self): + result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)) == N + + def test_nan_warmup(self): + result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14) + assert np.all(np.isnan(result[:14])) + + def test_constant_price_is_50(self): + # When money flow is neither positive nor negative → MFI should be near 50 + # Use alternating tiny moves around constant so no clear direction + c = np.full(20, 100.0) + h = np.full(20, 101.0) + l = np.full(20, 99.0) + v = np.full(20, 1000.0) + result = MFI(h, l, c, v, 5) + valid = result[~np.isnan(result)] + assert len(valid) > 0 # just ensure it runs + + +# --------------------------------------------------------------------------- +# MOM +# --------------------------------------------------------------------------- + + +class TestMOM: + def test_known_values(self): + result = MOM(SMALL5, timeperiod=2) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 2.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 2.0, rtol=1e-10) + + def test_length(self): + assert len(MOM(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# ROC +# --------------------------------------------------------------------------- + + +class TestROC: + def test_known_values(self): + arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + result = ROC(arr, 2) + # ROC = ((close - close[n]) / close[n]) * 100 + np.testing.assert_allclose(result[2], (12 - 10) / 10 * 100, rtol=1e-10) + + def test_length(self): + assert len(ROC(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# ROCP +# --------------------------------------------------------------------------- + + +class TestROCP: + def test_known_values(self): + arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + result = ROCP(arr, 2) + # ROCP = (close - close[n]) / close[n] + np.testing.assert_allclose(result[2], (12 - 10) / 10, rtol=1e-10) + + def test_length(self): + assert len(ROCP(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# ROCR +# --------------------------------------------------------------------------- + + +class TestROCR: + def test_known_values(self): + arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + result = ROCR(arr, 2) + # ROCR = close / close[n] + np.testing.assert_allclose(result[2], 12 / 10, rtol=1e-10) + np.testing.assert_allclose(result[4], 14 / 12, rtol=1e-10) + + def test_nan_warmup(self): + result = ROCR(_CLOSE, 10) + assert np.all(np.isnan(result[:10])) + + def test_length(self): + assert len(ROCR(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# ROCR100 +# --------------------------------------------------------------------------- + + +class TestROCR100: + def test_known_values(self): + arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + result = ROCR100(arr, 2) + # ROCR100 = (close / close[n]) * 100 + np.testing.assert_allclose(result[2], 12 / 10 * 100, rtol=1e-10) + + def test_relation_to_rocr(self): + rocr = ROCR(_CLOSE, 5) + rocr100 = ROCR100(_CLOSE, 5) + valid = ~np.isnan(rocr) + np.testing.assert_allclose(rocr100[valid], rocr[valid] * 100, rtol=1e-10) + + def test_length(self): + assert len(ROCR100(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# CMO +# --------------------------------------------------------------------------- + + +class TestCMO: + def test_range(self): + result = CMO(_CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= -100) and np.all(valid <= 100) + + def test_length(self): + assert len(CMO(_CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# DX +# --------------------------------------------------------------------------- + + +class TestDX: + def test_range(self): + result = DX(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(DX(_HIGH, _LOW, _CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# MINUS_DI / MINUS_DM +# --------------------------------------------------------------------------- + + +class TestMINUS: + def test_minus_di_range(self): + result = MINUS_DI(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + def test_minus_dm_range(self): + result = MINUS_DM(_HIGH, _LOW, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + def test_lengths(self): + assert len(MINUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N + assert len(MINUS_DM(_HIGH, _LOW, 14)) == N + + +# --------------------------------------------------------------------------- +# PLUS_DI / PLUS_DM +# --------------------------------------------------------------------------- + + +class TestPLUS: + def test_plus_di_range(self): + result = PLUS_DI(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + def test_plus_dm_range(self): + result = PLUS_DM(_HIGH, _LOW, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + def test_lengths(self): + assert len(PLUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N + assert len(PLUS_DM(_HIGH, _LOW, 14)) == N + + +# --------------------------------------------------------------------------- +# PPO +# --------------------------------------------------------------------------- + + +class TestPPO: + def test_returns_three_arrays(self): + result = PPO(_CLOSE, fastperiod=12, slowperiod=26) + assert isinstance(result, tuple) and len(result) == 3 + + def test_histogram_is_diff(self): + ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26) + valid = ~np.isnan(ppo) & ~np.isnan(signal) + np.testing.assert_allclose(hist[valid], ppo[valid] - signal[valid], atol=1e-10) + + def test_length(self): + ppo, signal, hist = PPO(_CLOSE) + assert len(ppo) == len(signal) == len(hist) == N + + def test_nan_warmup(self): + ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26) + assert np.any(np.isnan(ppo)) + + +# --------------------------------------------------------------------------- +# APO +# --------------------------------------------------------------------------- + + +class TestAPO: + def test_known_direction(self): + # Rising close → fast EMA > slow EMA → APO > 0 after warmup + rising = np.linspace(1.0, 100.0, 60) + result = APO(rising, fastperiod=5, slowperiod=10) + valid = result[~np.isnan(result)] + assert np.all(valid > 0) + + def test_length(self): + assert len(APO(_CLOSE, 12, 26)) == N + + def test_nan_warmup(self): + result = APO(_CLOSE, 12, 26) + assert np.any(np.isnan(result)) + + +# --------------------------------------------------------------------------- +# TRIX +# --------------------------------------------------------------------------- + + +class TestTRIX: + def test_length(self): + assert len(TRIX(_CLOSE, 10)) == N + + def test_nan_warmup(self): + result = TRIX(_CLOSE, timeperiod=5) + # TRIX warmup = 3*(tp-1) for triple EMA + 1 for diff + assert np.all(np.isnan(result[:12])) + + def test_finite_after_warmup(self): + result = TRIX(_CLOSE, timeperiod=5) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_rising_series_positive(self): + rising = np.linspace(1.0, 200.0, 100) + result = TRIX(rising, timeperiod=5) + valid = result[~np.isnan(result)] + # On monotone rise, rate of change of triple EMA is positive + assert np.all(valid > 0) + + +# --------------------------------------------------------------------------- +# BOP +# --------------------------------------------------------------------------- + + +class TestBOP: + def test_known_values(self): + o = np.array([10.0, 11.0]) + h = np.array([14.0, 15.0]) + l = np.array([8.0, 9.0]) + c = np.array([12.0, 13.0]) + # BOP = (close - open) / (high - low) + result = BOP(o, h, l, c) + np.testing.assert_allclose(result[0], (12 - 10) / (14 - 8), rtol=1e-10) + np.testing.assert_allclose(result[1], (13 - 11) / (15 - 9), rtol=1e-10) + + def test_bearish_is_negative(self): + o = np.array([14.0, 14.0]) + h = np.array([15.0, 15.0]) + l = np.array([8.0, 8.0]) + c = np.array([10.0, 10.0]) + result = BOP(o, h, l, c) + assert np.all(result < 0) + + def test_range(self): + # BOP = (close - open) / (high - low); can exceed [-1,1] with noisy data + result = BOP(_OPEN, _HIGH, _LOW, _CLOSE) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(BOP(_OPEN, _HIGH, _LOW, _CLOSE)) == N + + +# --------------------------------------------------------------------------- +# ULTOSC +# --------------------------------------------------------------------------- + + +class TestULTOSC: + def test_range(self): + result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)) == N + + def test_nan_warmup(self): + result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28) + assert np.any(np.isnan(result)) diff --git a/ferro-ta-main/tests/unit/indicators/test_overlap.py b/ferro-ta-main/tests/unit/indicators/test_overlap.py new file mode 100644 index 0000000..fa471af --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_overlap.py @@ -0,0 +1,484 @@ +"""Unit tests for ferro_ta.indicators.overlap""" + +import numpy as np + +from ferro_ta.indicators.overlap import ( + BBANDS, + DEMA, + EMA, + KAMA, + MA, + MACD, + MACDEXT, + MACDFIX, + MAMA, + MAVP, + MIDPOINT, + MIDPRICE, + SAR, + SAREXT, + SMA, + T3, + TEMA, + TRIMA, + WMA, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +N = 200 +_CLOSE = 100 + 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)) + +SMALL5 = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) +SMALL5_HIGH = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) +SMALL5_LOW = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) + + +# --------------------------------------------------------------------------- +# SMA +# --------------------------------------------------------------------------- + + +class TestSMA: + def test_known_values(self): + result = SMA(SMALL5, timeperiod=3) + expected = np.array([np.nan, np.nan, 11.0, 12.0, 13.0]) + np.testing.assert_allclose(result[2:], expected[2:], rtol=1e-10) + + def test_nan_warmup(self): + result = SMA(SMALL5, timeperiod=3) + assert np.all(np.isnan(result[:2])) + + def test_length(self): + result = SMA(_CLOSE, timeperiod=20) + assert len(result) == N + + def test_nan_warmup_long(self): + result = SMA(_CLOSE, timeperiod=20) + assert np.all(np.isnan(result[:19])) + assert np.all(np.isfinite(result[19:])) + + +# --------------------------------------------------------------------------- +# EMA +# --------------------------------------------------------------------------- + + +class TestEMA: + def test_known_values(self): + # k = 2/(3+1) = 0.5; seed = SMA(3) = 11.0 + # EMA[2] = SMA([10,11,12]) = 11.0 + # EMA[3] = close[3]*k + EMA[2]*(1-k) = 13*0.5 + 11.0*0.5 = 12.0 + # EMA[4] = close[4]*k + EMA[3]*(1-k) = 14*0.5 + 12.0*0.5 = 13.0 + result = EMA(SMALL5, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 11.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 12.0, rtol=1e-10) + np.testing.assert_allclose(result[4], 13.0, rtol=1e-10) + + def test_nan_warmup(self): + result = EMA(SMALL5, timeperiod=3) + assert np.all(np.isnan(result[:2])) + + def test_length(self): + assert len(EMA(_CLOSE, 20)) == N + + def test_monotone_on_rising(self): + rising = np.arange(1.0, 51.0) + result = EMA(rising, 5) + valid = result[~np.isnan(result)] + assert np.all(np.diff(valid) > 0) + + +# --------------------------------------------------------------------------- +# WMA +# --------------------------------------------------------------------------- + + +class TestWMA: + def test_known_values(self): + arr = np.arange(1.0, 6.0) + result = WMA(arr, timeperiod=3) + # weights 1,2,3 / 6 + expected_2 = (1 * 1 + 2 * 2 + 3 * 3) / 6.0 # 14/6 + expected_3 = (1 * 2 + 2 * 3 + 3 * 4) / 6.0 # 20/6 + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], expected_2, rtol=1e-10) + np.testing.assert_allclose(result[3], expected_3, rtol=1e-10) + + def test_nan_warmup(self): + result = WMA(_CLOSE, timeperiod=10) + assert np.all(np.isnan(result[:9])) + + def test_length(self): + assert len(WMA(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# DEMA +# --------------------------------------------------------------------------- + + +class TestDEMA: + def test_nan_warmup(self): + result = DEMA(_CLOSE, timeperiod=5) + assert np.all(np.isnan(result[:8])) # DEMA needs 2*(tp-1) bars + + def test_length(self): + assert len(DEMA(_CLOSE, 5)) == N + + def test_values_finite_after_warmup(self): + result = DEMA(_CLOSE, timeperiod=5) + valid = result[~np.isnan(result)] + assert len(valid) > 0 + assert np.all(np.isfinite(valid)) + + def test_tracks_close(self): + # DEMA is more responsive than EMA; on trending data it should lead EMA + rising = np.linspace(10.0, 100.0, 100) + dema = DEMA(rising, 5) + ema = EMA(rising, 5) + valid = ~np.isnan(dema) & ~np.isnan(ema) + # DEMA > EMA on a rising series (lower lag) + assert np.all(dema[valid] >= ema[valid] - 1e-9) + + +# --------------------------------------------------------------------------- +# TEMA +# --------------------------------------------------------------------------- + + +class TestTEMA: + def test_nan_warmup(self): + result = TEMA(_CLOSE, timeperiod=5) + assert np.all(np.isnan(result[:12])) + + def test_length(self): + assert len(TEMA(_CLOSE, 5)) == N + + def test_values_finite_after_warmup(self): + result = TEMA(_CLOSE, timeperiod=5) + valid = result[~np.isnan(result)] + assert len(valid) > 0 + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# TRIMA +# --------------------------------------------------------------------------- + + +class TestTRIMA: + def test_known_values(self): + arr = np.arange(1.0, 11.0) + result = TRIMA(arr, timeperiod=5) + # TRIMA(5) is SMA of SMA(3) on a 5-window + assert np.all(np.isnan(result[:4])) + np.testing.assert_allclose(result[4], 3.0, rtol=1e-10) + np.testing.assert_allclose(result[5], 4.0, rtol=1e-10) + + def test_nan_warmup(self): + result = TRIMA(_CLOSE, timeperiod=10) + assert np.all(np.isnan(result[:9])) + + def test_length(self): + assert len(TRIMA(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# KAMA +# --------------------------------------------------------------------------- + + +class TestKAMA: + def test_nan_warmup(self): + result = KAMA(_CLOSE, timeperiod=10) + assert np.all(np.isnan(result[:9])) + + def test_length(self): + assert len(KAMA(_CLOSE, 10)) == N + + def test_seed_equals_close(self): + arr = np.arange(1.0, 21.0) + result = KAMA(arr, timeperiod=10) + # First valid KAMA value equals close at warmup index + np.testing.assert_allclose(result[9], arr[9], rtol=1e-10) + + def test_finite_after_warmup(self): + result = KAMA(_CLOSE, timeperiod=10) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# T3 +# --------------------------------------------------------------------------- + + +class TestT3: + def test_nan_warmup(self): + arr = np.linspace(10.0, 30.0, 100) + result = T3(arr, timeperiod=5) + # warmup for T3(tp) = 6*(tp-1) + assert np.all(np.isnan(result[:24])) + + def test_length(self): + assert len(T3(_CLOSE, timeperiod=5)) == N + + def test_finite_after_warmup(self): + arr = np.linspace(10.0, 30.0, 100) + result = T3(arr, timeperiod=5) + valid = result[~np.isnan(result)] + assert len(valid) > 0 + assert np.all(np.isfinite(valid)) + + def test_trending(self): + rising = np.linspace(10.0, 200.0, 150) + result = T3(rising, timeperiod=5) + valid = result[~np.isnan(result)] + assert np.all(np.diff(valid) > 0) + + +# --------------------------------------------------------------------------- +# MA +# --------------------------------------------------------------------------- + + +class TestMA: + def test_default_is_sma(self): + result_ma = MA(_CLOSE, timeperiod=10, matype=0) + result_sma = SMA(_CLOSE, timeperiod=10) + np.testing.assert_allclose(result_ma, result_sma, rtol=1e-10, equal_nan=True) + + def test_ema_matype(self): + result_ma = MA(_CLOSE, timeperiod=10, matype=1) + result_ema = EMA(_CLOSE, timeperiod=10) + np.testing.assert_allclose(result_ma, result_ema, rtol=1e-10, equal_nan=True) + + def test_length(self): + assert len(MA(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# MACD +# --------------------------------------------------------------------------- + + +class TestMACD: + def test_returns_three_arrays(self): + result = MACD(_CLOSE, 12, 26, 9) + assert isinstance(result, tuple) and len(result) == 3 + + def test_length(self): + macd, signal, hist = MACD(_CLOSE, 12, 26, 9) + assert len(macd) == len(signal) == len(hist) == N + + def test_histogram_is_diff(self): + macd, signal, hist = MACD(_CLOSE) + valid = ~np.isnan(macd) & ~np.isnan(signal) + np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10) + + def test_nan_warmup(self): + macd, signal, hist = MACD(_CLOSE, 12, 26, 9) + # MACD line: warmup = slowperiod - 1 = 25 + assert np.all(np.isnan(macd[:25])) + + +# --------------------------------------------------------------------------- +# MACDFIX +# --------------------------------------------------------------------------- + + +class TestMACDFIX: + def test_returns_three_arrays(self): + result = MACDFIX(_CLOSE) + assert isinstance(result, tuple) and len(result) == 3 + + def test_histogram_is_diff(self): + macd, signal, hist = MACDFIX(_CLOSE) + valid = ~np.isnan(macd) & ~np.isnan(signal) + np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10) + + def test_length(self): + macd, signal, hist = MACDFIX(_CLOSE) + assert len(macd) == N + + +# --------------------------------------------------------------------------- +# MACDEXT +# --------------------------------------------------------------------------- + + +class TestMACDEXT: + def test_returns_three_arrays(self): + result = MACDEXT(_CLOSE) + assert isinstance(result, tuple) and len(result) == 3 + + def test_histogram_is_diff(self): + macd, signal, hist = MACDEXT(_CLOSE) + valid = ~np.isnan(macd) & ~np.isnan(signal) + np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10) + + def test_length(self): + assert len(MACDEXT(_CLOSE)[0]) == N + + +# --------------------------------------------------------------------------- +# BBANDS +# --------------------------------------------------------------------------- + + +class TestBBANDS: + def test_returns_three_arrays(self): + result = BBANDS(_CLOSE, 20) + assert isinstance(result, tuple) and len(result) == 3 + + def test_middle_is_sma(self): + upper, middle, lower = BBANDS(_CLOSE, timeperiod=20) + sma = SMA(_CLOSE, timeperiod=20) + np.testing.assert_allclose(middle, sma, rtol=1e-10, equal_nan=True) + + def test_bands_symmetric(self): + upper, middle, lower = BBANDS(_CLOSE, 20, nbdevup=2.0, nbdevdn=2.0) + valid = ~np.isnan(upper) + np.testing.assert_allclose( + upper[valid] - middle[valid], + middle[valid] - lower[valid], + rtol=1e-10, + ) + + def test_nan_warmup(self): + upper, middle, lower = BBANDS(_CLOSE, 20) + assert np.all(np.isnan(middle[:19])) + + +# --------------------------------------------------------------------------- +# SAR +# --------------------------------------------------------------------------- + + +class TestSAR: + def test_length(self): + result = SAR(_HIGH, _LOW) + assert len(result) == N + + def test_first_is_nan(self): + result = SAR(_HIGH, _LOW) + assert np.isnan(result[0]) + + def test_finite_after_warmup(self): + result = SAR(_HIGH, _LOW) + assert np.all(np.isfinite(result[1:])) + + +# --------------------------------------------------------------------------- +# SAREXT +# --------------------------------------------------------------------------- + + +class TestSAREXT: + def test_length(self): + result = SAREXT(_HIGH, _LOW) + assert len(result) == N + + def test_first_is_nan(self): + result = SAREXT(_HIGH, _LOW) + assert np.isnan(result[0]) + + def test_finite_after_warmup(self): + result = SAREXT(_HIGH, _LOW) + assert np.all(np.isfinite(result[1:])) + + +# --------------------------------------------------------------------------- +# MAMA +# --------------------------------------------------------------------------- + + +class TestMAMA: + def test_returns_two_arrays(self): + result = MAMA(_CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + mama, fama = MAMA(_CLOSE) + assert len(mama) == len(fama) == N + + def test_nan_warmup(self): + mama, fama = MAMA(_CLOSE) + assert np.all(np.isnan(mama[:32])) + + def test_mama_ge_fama(self): + # MAMA is adaptive; on average MAMA >= FAMA on a trending up series + rising = np.linspace(10.0, 200.0, 200) + mama, fama = MAMA(rising) + valid = ~np.isnan(mama) & ~np.isnan(fama) + # not strictly guaranteed, just check output is finite + assert np.all(np.isfinite(mama[valid])) + + +# --------------------------------------------------------------------------- +# MAVP +# --------------------------------------------------------------------------- + + +class TestMAVP: + def test_length(self): + arr = np.linspace(10.0, 30.0, 50) + periods = np.full(50, 5.0) + result = MAVP(arr, periods, minperiod=2, maxperiod=10) + assert len(result) == 50 + + def test_finite_for_large_enough_data(self): + arr = np.linspace(10.0, 30.0, 50) + periods = np.full(50, 3.0) + result = MAVP(arr, periods, minperiod=2, maxperiod=10) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# MIDPOINT +# --------------------------------------------------------------------------- + + +class TestMIDPOINT: + def test_known_values(self): + arr = np.array([10.0, 12.0, 14.0, 16.0, 18.0]) + result = MIDPOINT(arr, timeperiod=3) + # MIDPOINT(n) = (max + min) / 2 over window + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], (10.0 + 14.0) / 2.0, rtol=1e-10) + np.testing.assert_allclose(result[4], (14.0 + 18.0) / 2.0, rtol=1e-10) + + def test_nan_warmup(self): + result = MIDPOINT(_CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_length(self): + assert len(MIDPOINT(_CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# MIDPRICE +# --------------------------------------------------------------------------- + + +class TestMIDPRICE: + def test_known_values(self): + result = MIDPRICE(SMALL5_HIGH, SMALL5_LOW, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + # window [0..2]: max_high=13, min_low=9 → (13+9)/2 = 11 + np.testing.assert_allclose(result[2], 11.0, rtol=1e-10) + + def test_nan_warmup(self): + result = MIDPRICE(_HIGH, _LOW, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_length(self): + assert len(MIDPRICE(_HIGH, _LOW, 14)) == N diff --git a/ferro-ta-main/tests/unit/indicators/test_pattern.py b/ferro-ta-main/tests/unit/indicators/test_pattern.py new file mode 100644 index 0000000..cce26c1 --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_pattern.py @@ -0,0 +1,260 @@ +"""Unit tests for ferro_ta.indicators.pattern (CDL* functions)""" + +import numpy as np +import pytest + +from ferro_ta.indicators.pattern import ( + CDL2CROWS, + CDL3BLACKCROWS, + CDL3INSIDE, + CDL3LINESTRIKE, + CDL3OUTSIDE, + CDL3STARSINSOUTH, + CDL3WHITESOLDIERS, + CDLABANDONEDBABY, + CDLADVANCEBLOCK, + CDLBELTHOLD, + CDLBREAKAWAY, + CDLCLOSINGMARUBOZU, + CDLCONCEALBABYSWALL, + CDLCOUNTERATTACK, + CDLDARKCLOUDCOVER, + CDLDOJI, + CDLDOJISTAR, + CDLDRAGONFLYDOJI, + CDLENGULFING, + CDLEVENINGDOJISTAR, + CDLEVENINGSTAR, + CDLGAPSIDESIDEWHITE, + CDLGRAVESTONEDOJI, + CDLHAMMER, + CDLHANGINGMAN, + CDLHARAMI, + CDLHARAMICROSS, + CDLHIGHWAVE, + CDLHIKKAKE, + CDLHIKKAKEMOD, + CDLHOMINGPIGEON, + CDLIDENTICAL3CROWS, + CDLINNECK, + CDLINVERTEDHAMMER, + CDLKICKING, + CDLKICKINGBYLENGTH, + CDLLADDERBOTTOM, + CDLLONGLEGGEDDOJI, + CDLLONGLINE, + CDLMARUBOZU, + CDLMATCHINGLOW, + CDLMATHOLD, + CDLMORNINGDOJISTAR, + CDLMORNINGSTAR, + CDLONNECK, + CDLPIERCING, + CDLRICKSHAWMAN, + CDLRISEFALL3METHODS, + CDLSEPARATINGLINES, + CDLSHOOTINGSTAR, + CDLSHORTLINE, + CDLSPINNINGTOP, + CDLSTALLEDPATTERN, + CDLSTICKSANDWICH, + CDLTAKURI, + CDLTASUKIGAP, + CDLTHRUSTING, + CDLTRISTAR, + CDLUNIQUE3RIVER, + CDLUPSIDEGAP2CROWS, + CDLXSIDEGAP3METHODS, +) + +# --------------------------------------------------------------------------- +# Shared random OHLCV data (realistic OHLCV, proper H >= O,C >= L) +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +N = 200 +_C = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_O = _C + RNG.normal(0, 0.2, N) +_H = np.maximum(np.maximum(_O, _C) + np.abs(RNG.normal(0, 0.3, N)), np.maximum(_O, _C)) +_L = np.minimum(np.minimum(_O, _C) - np.abs(RNG.normal(0, 0.3, N)), np.minimum(_O, _C)) + +# All CDL* functions to test systematically +ALL_CDL = [ + ("CDL2CROWS", CDL2CROWS), + ("CDL3BLACKCROWS", CDL3BLACKCROWS), + ("CDL3INSIDE", CDL3INSIDE), + ("CDL3LINESTRIKE", CDL3LINESTRIKE), + ("CDL3OUTSIDE", CDL3OUTSIDE), + ("CDL3STARSINSOUTH", CDL3STARSINSOUTH), + ("CDL3WHITESOLDIERS", CDL3WHITESOLDIERS), + ("CDLABANDONEDBABY", CDLABANDONEDBABY), + ("CDLADVANCEBLOCK", CDLADVANCEBLOCK), + ("CDLBELTHOLD", CDLBELTHOLD), + ("CDLBREAKAWAY", CDLBREAKAWAY), + ("CDLCLOSINGMARUBOZU", CDLCLOSINGMARUBOZU), + ("CDLCONCEALBABYSWALL", CDLCONCEALBABYSWALL), + ("CDLCOUNTERATTACK", CDLCOUNTERATTACK), + ("CDLDARKCLOUDCOVER", CDLDARKCLOUDCOVER), + ("CDLDOJI", CDLDOJI), + ("CDLDOJISTAR", CDLDOJISTAR), + ("CDLDRAGONFLYDOJI", CDLDRAGONFLYDOJI), + ("CDLENGULFING", CDLENGULFING), + ("CDLEVENINGDOJISTAR", CDLEVENINGDOJISTAR), + ("CDLEVENINGSTAR", CDLEVENINGSTAR), + ("CDLGAPSIDESIDEWHITE", CDLGAPSIDESIDEWHITE), + ("CDLGRAVESTONEDOJI", CDLGRAVESTONEDOJI), + ("CDLHAMMER", CDLHAMMER), + ("CDLHANGINGMAN", CDLHANGINGMAN), + ("CDLHARAMI", CDLHARAMI), + ("CDLHARAMICROSS", CDLHARAMICROSS), + ("CDLHIGHWAVE", CDLHIGHWAVE), + ("CDLHIKKAKE", CDLHIKKAKE), + ("CDLHIKKAKEMOD", CDLHIKKAKEMOD), + ("CDLHOMINGPIGEON", CDLHOMINGPIGEON), + ("CDLIDENTICAL3CROWS", CDLIDENTICAL3CROWS), + ("CDLINNECK", CDLINNECK), + ("CDLINVERTEDHAMMER", CDLINVERTEDHAMMER), + ("CDLKICKING", CDLKICKING), + ("CDLKICKINGBYLENGTH", CDLKICKINGBYLENGTH), + ("CDLLADDERBOTTOM", CDLLADDERBOTTOM), + ("CDLLONGLEGGEDDOJI", CDLLONGLEGGEDDOJI), + ("CDLLONGLINE", CDLLONGLINE), + ("CDLMARUBOZU", CDLMARUBOZU), + ("CDLMATCHINGLOW", CDLMATCHINGLOW), + ("CDLMATHOLD", CDLMATHOLD), + ("CDLMORNINGDOJISTAR", CDLMORNINGDOJISTAR), + ("CDLMORNINGSTAR", CDLMORNINGSTAR), + ("CDLONNECK", CDLONNECK), + ("CDLPIERCING", CDLPIERCING), + ("CDLRICKSHAWMAN", CDLRICKSHAWMAN), + ("CDLRISEFALL3METHODS", CDLRISEFALL3METHODS), + ("CDLSEPARATINGLINES", CDLSEPARATINGLINES), + ("CDLSHOOTINGSTAR", CDLSHOOTINGSTAR), + ("CDLSHORTLINE", CDLSHORTLINE), + ("CDLSPINNINGTOP", CDLSPINNINGTOP), + ("CDLSTALLEDPATTERN", CDLSTALLEDPATTERN), + ("CDLSTICKSANDWICH", CDLSTICKSANDWICH), + ("CDLTAKURI", CDLTAKURI), + ("CDLTASUKIGAP", CDLTASUKIGAP), + ("CDLTHRUSTING", CDLTHRUSTING), + ("CDLTRISTAR", CDLTRISTAR), + ("CDLUNIQUE3RIVER", CDLUNIQUE3RIVER), + ("CDLUPSIDEGAP2CROWS", CDLUPSIDEGAP2CROWS), + ("CDLXSIDEGAP3METHODS", CDLXSIDEGAP3METHODS), +] + + +# --------------------------------------------------------------------------- +# Parametrised tests: all CDL patterns +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name,fn", ALL_CDL) +def test_cdl_output_length(name, fn): + result = fn(_O, _H, _L, _C) + assert len(result) == N, f"{name}: expected length {N}, got {len(result)}" + + +@pytest.mark.parametrize("name,fn", ALL_CDL) +def test_cdl_values_in_valid_set(name, fn): + result = fn(_O, _H, _L, _C) + assert np.all(np.isin(result, [-100, 0, 100])), ( + f"{name}: unexpected values {np.unique(result)}" + ) + + +@pytest.mark.parametrize("name,fn", ALL_CDL) +def test_cdl_no_nan(name, fn): + result = fn(_O, _H, _L, _C) + assert np.all(np.isfinite(result.astype(float))), f"{name}: contains NaN/Inf" + + +# --------------------------------------------------------------------------- +# Specific tests for previously untested patterns +# --------------------------------------------------------------------------- + + +class TestCDLSPINNINGTOP: + def test_detects_pattern(self): + # Spinning top: small body, long upper and lower shadows + # open ≈ close (small body), high much higher, low much lower + o = np.array([10.0, 10.1, 10.0]) + h = np.array([15.0, 15.1, 15.0]) + l = np.array([5.0, 5.1, 5.0]) + c = np.array([10.0, 10.0, 10.05]) + result = CDLSPINNINGTOP(o, h, l, c) + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_output_values_random(self): + result = CDLSPINNINGTOP(_O, _H, _L, _C) + assert np.all(np.isin(result, [-100, 0, 100])) + + +class TestCDLEVENINGSTAR: + def test_basic_run(self): + result = CDLEVENINGSTAR(_O, _H, _L, _C) + assert len(result) == N + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_large_dataset_has_valid_output(self): + # On 200 bars of random data, result should be all in {-100,0,100} + result = CDLEVENINGSTAR(_O, _H, _L, _C) + assert np.all(np.isin(result, [-100, 0, 100])) + + +class TestCDLMORNINGSTAR: + def test_basic_run(self): + result = CDLMORNINGSTAR(_O, _H, _L, _C) + assert len(result) == N + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_bullish_signal_is_100(self): + # Any detected signal must be 100 (bullish) + result = CDLMORNINGSTAR(_O, _H, _L, _C) + assert np.all(result[result != 0] == 100) + + +class TestCDL2CROWS: + def test_basic_run(self): + result = CDL2CROWS(_O, _H, _L, _C) + assert len(result) == N + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_bearish_signal_is_minus_100(self): + # Any detected signal must be -100 (bearish) + result = CDL2CROWS(_O, _H, _L, _C) + assert np.all(result[result != 0] == -100) + + +class TestCDLDOJI: + def test_detects_doji(self): + # Exact doji: open == close + o = np.array([10.0, 10.0, 10.0]) + h = np.array([12.0, 12.0, 12.0]) + l = np.array([8.0, 8.0, 8.0]) + c = np.array([10.0, 10.0, 10.0]) + result = CDLDOJI(o, h, l, c) + assert np.all(result == 100) + + def test_non_doji_returns_zero(self): + o = np.array([10.0, 11.0, 12.0]) + h = np.array([15.0, 16.0, 17.0]) + l = np.array([9.0, 10.0, 11.0]) + c = np.array([14.0, 15.0, 16.0]) # large body, not doji + result = CDLDOJI(o, h, l, c) + assert np.all(result == 0) + + +class TestCDLMARUBOZU: + def test_detects_bullish_marubozu(self): + # Bullish marubozu: open == low, close == high, close > open + o = np.array([10.0, 10.0]) + h = np.array([15.0, 15.0]) + l = np.array([10.0, 10.0]) + c = np.array([15.0, 15.0]) + result = CDLMARUBOZU(o, h, l, c) + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_length(self): + result = CDLMARUBOZU(_O, _H, _L, _C) + assert len(result) == N diff --git a/ferro-ta-main/tests/unit/indicators/test_price_transform.py b/ferro-ta-main/tests/unit/indicators/test_price_transform.py new file mode 100644 index 0000000..6c27adf --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_price_transform.py @@ -0,0 +1,113 @@ +"""Unit tests for ferro_ta.indicators.price_transform""" + +import numpy as np + +from ferro_ta.indicators.price_transform import AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +O = np.array([10.0, 11.0, 12.0, 13.0]) +H = np.array([12.0, 13.0, 14.0, 15.0]) +L = np.array([9.0, 10.0, 11.0, 12.0]) +C = np.array([11.0, 12.0, 13.0, 14.0]) + + +# --------------------------------------------------------------------------- +# AVGPRICE +# --------------------------------------------------------------------------- + + +class TestAVGPRICE: + def test_known_formula(self): + result = AVGPRICE(O, H, L, C) + expected = (O + H + L + C) / 4.0 + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_first_bar(self): + result = AVGPRICE(O, H, L, C) + np.testing.assert_allclose(result[0], (10 + 12 + 9 + 11) / 4.0, rtol=1e-10) + + def test_no_nan(self): + result = AVGPRICE(O, H, L, C) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(AVGPRICE(O, H, L, C)) == len(O) + + +# --------------------------------------------------------------------------- +# MEDPRICE +# --------------------------------------------------------------------------- + + +class TestMEDPRICE: + def test_known_formula(self): + result = MEDPRICE(H, L) + expected = (H + L) / 2.0 + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_first_bar(self): + result = MEDPRICE(H, L) + np.testing.assert_allclose(result[0], (12 + 9) / 2.0, rtol=1e-10) + + def test_no_nan(self): + result = MEDPRICE(H, L) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(MEDPRICE(H, L)) == len(H) + + +# --------------------------------------------------------------------------- +# TYPPRICE +# --------------------------------------------------------------------------- + + +class TestTYPPRICE: + def test_known_formula(self): + result = TYPPRICE(H, L, C) + expected = (H + L + C) / 3.0 + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_first_bar(self): + result = TYPPRICE(H, L, C) + np.testing.assert_allclose(result[0], (12 + 9 + 11) / 3.0, rtol=1e-10) + + def test_no_nan(self): + result = TYPPRICE(H, L, C) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(TYPPRICE(H, L, C)) == len(H) + + +# --------------------------------------------------------------------------- +# WCLPRICE +# --------------------------------------------------------------------------- + + +class TestWCLPRICE: + def test_known_formula(self): + result = WCLPRICE(H, L, C) + expected = (H + L + 2.0 * C) / 4.0 + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_first_bar(self): + result = WCLPRICE(H, L, C) + np.testing.assert_allclose(result[0], (12 + 9 + 2 * 11) / 4.0, rtol=1e-10) + + def test_no_nan(self): + result = WCLPRICE(H, L, C) + assert np.all(np.isfinite(result)) + + def test_close_weight_double(self): + # WCLPRICE weights close twice vs TYPPRICE + wcl = WCLPRICE(H, L, C) + # On a rising series (H > L > 0), WCLPRICE > TYPPRICE when C > (H+L)/2 + # Just verify formula correctness already done above + assert np.all(np.isfinite(wcl)) + + def test_length(self): + assert len(WCLPRICE(H, L, C)) == len(H) diff --git a/ferro-ta-main/tests/unit/indicators/test_statistic.py b/ferro-ta-main/tests/unit/indicators/test_statistic.py new file mode 100644 index 0000000..8487785 --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_statistic.py @@ -0,0 +1,488 @@ +"""Unit tests for ferro_ta.indicators.statistic""" + +import numpy as np +import pytest + +from ferro_ta.indicators.statistic import ( + BATCH_DTW, + BETA, + CORREL, + DTW, + DTW_DISTANCE, + LINEARREG, + LINEARREG_ANGLE, + LINEARREG_INTERCEPT, + LINEARREG_SLOPE, + STDDEV, + TSF, + VAR, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(11) +N = 100 +_A = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_B = 100 + np.cumsum(RNG.normal(0, 0.5, N)) + +LINDATA = np.arange(1.0, 6.0) # [1,2,3,4,5] +CONSTDATA = np.ones(10) # all 1.0 + + +def _naive_linreg_window(window: np.ndarray) -> tuple[float, float]: + x = np.arange(len(window), dtype=np.float64) + sum_x = float(np.sum(x)) + sum_y = float(np.sum(window)) + sum_xy = float(np.sum(x * window)) + sum_x2 = float(np.sum(x * x)) + n = float(len(window)) + denom = n * sum_x2 - sum_x * sum_x + slope = (n * sum_xy - sum_x * sum_y) / denom if denom != 0.0 else 0.0 + intercept = (sum_y - slope * sum_x) / n + return slope, intercept + + +def _naive_linearreg(series: np.ndarray, timeperiod: int, x_value: float) -> np.ndarray: + out = np.full(len(series), np.nan, dtype=np.float64) + for end in range(timeperiod - 1, len(series)): + slope, intercept = _naive_linreg_window(series[end + 1 - timeperiod : end + 1]) + out[end] = intercept + slope * x_value + return out + + +def _naive_correl(x: np.ndarray, y: np.ndarray, timeperiod: int) -> np.ndarray: + out = np.full(len(x), np.nan, dtype=np.float64) + for end in range(timeperiod - 1, len(x)): + x_window = x[end + 1 - timeperiod : end + 1] + y_window = y[end + 1 - timeperiod : end + 1] + mean_x = float(np.sum(x_window)) / timeperiod + mean_y = float(np.sum(y_window)) / timeperiod + cov = float(np.sum((x_window - mean_x) * (y_window - mean_y))) + std_x = float(np.sqrt(np.sum((x_window - mean_x) ** 2))) + std_y = float(np.sqrt(np.sum((y_window - mean_y) ** 2))) + denom = std_x * std_y + out[end] = cov / denom if denom != 0.0 else np.nan + return out + + +def _naive_beta(x: np.ndarray, y: np.ndarray, timeperiod: int) -> np.ndarray: + out = np.full(len(x), np.nan, dtype=np.float64) + for end in range(timeperiod, len(x)): + start = end - timeperiod + rx = np.array( + [ + x[idx + 1] / x[idx] - 1.0 if x[idx] != 0.0 else np.nan + for idx in range(start, end) + ], + dtype=np.float64, + ) + ry = np.array( + [ + y[idx + 1] / y[idx] - 1.0 if y[idx] != 0.0 else np.nan + for idx in range(start, end) + ], + dtype=np.float64, + ) + mean_x = float(np.sum(rx)) / timeperiod + mean_y = float(np.sum(ry)) / timeperiod + cov = float(np.sum((rx - mean_x) * (ry - mean_y))) / timeperiod + var_x = float(np.sum((rx - mean_x) ** 2)) / timeperiod + out[end] = cov / var_x if var_x != 0.0 else np.nan + return out + + +# --------------------------------------------------------------------------- +# STDDEV +# --------------------------------------------------------------------------- + + +class TestSTDDEV: + def test_constant_is_zero(self): + result = STDDEV(CONSTDATA, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-10) + + def test_known_values(self): + # std([1,2,3,4,5], ddof=0) = sqrt(2) + result = STDDEV(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], np.sqrt(2.0), rtol=1e-6) + + def test_nan_warmup(self): + result = STDDEV(_A, timeperiod=5) + assert np.all(np.isnan(result[:4])) + + def test_length(self): + assert len(STDDEV(_A, 5)) == N + + def test_positive(self): + result = STDDEV(_A, 5) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + +# --------------------------------------------------------------------------- +# VAR +# --------------------------------------------------------------------------- + + +class TestVAR: + def test_constant_is_zero(self): + result = VAR(CONSTDATA, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-10) + + def test_known_values(self): + # var([1,2,3,4,5], ddof=0) = 2.0 + result = VAR(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], 2.0, rtol=1e-6) + + def test_equals_stddev_squared(self): + std = STDDEV(_A, timeperiod=10) + var = VAR(_A, timeperiod=10) + valid = ~np.isnan(std) & ~np.isnan(var) + np.testing.assert_allclose(var[valid], std[valid] ** 2, rtol=1e-6) + + def test_length(self): + assert len(VAR(_A, 5)) == N + + +# --------------------------------------------------------------------------- +# LINEARREG +# --------------------------------------------------------------------------- + + +class TestLINEARREG: + def test_perfect_line(self): + # For [1,2,3,4,5] over window 5, forecast = 5.0 + result = LINEARREG(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], 5.0, rtol=1e-10) + + def test_nan_warmup(self): + result = LINEARREG(_A, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_length(self): + assert len(LINEARREG(_A, 14)) == N + + def test_matches_naive_regression(self): + expected = _naive_linearreg(_A, timeperiod=14, x_value=13.0) + result = LINEARREG(_A, timeperiod=14) + np.testing.assert_allclose(result, expected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# LINEARREG_SLOPE +# --------------------------------------------------------------------------- + + +class TestLINEARREG_SLOPE: + def test_perfect_line_slope_one(self): + result = LINEARREG_SLOPE(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], 1.0, rtol=1e-10) + + def test_constant_slope_zero(self): + result = LINEARREG_SLOPE(CONSTDATA, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-10) + + def test_length(self): + assert len(LINEARREG_SLOPE(_A, 14)) == N + + +# --------------------------------------------------------------------------- +# LINEARREG_INTERCEPT +# --------------------------------------------------------------------------- + + +class TestLINEARREG_INTERCEPT: + def test_perfect_line_intercept_one(self): + # y = [1,2,3,4,5] with x=[0,1,2,3,4] → y = 1 + 1*x → intercept = 1.0 + result = LINEARREG_INTERCEPT(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], 1.0, atol=1e-10) + + def test_length(self): + assert len(LINEARREG_INTERCEPT(_A, 14)) == N + + +# --------------------------------------------------------------------------- +# LINEARREG_ANGLE +# --------------------------------------------------------------------------- + + +class TestLINEARREG_ANGLE: + def test_slope_one_gives_45_degrees(self): + result = LINEARREG_ANGLE(LINDATA, timeperiod=5) + # arctan(1) * 180/pi = 45 + np.testing.assert_allclose(result[4], 45.0, rtol=1e-6) + + def test_constant_gives_zero_degrees(self): + result = LINEARREG_ANGLE(CONSTDATA, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-8) + + def test_length(self): + assert len(LINEARREG_ANGLE(_A, 14)) == N + + +# --------------------------------------------------------------------------- +# BETA +# --------------------------------------------------------------------------- + + +class TestBETA: + def test_nan_warmup(self): + result = BETA(_A, _B, timeperiod=5) + assert np.all(np.isnan(result[:4])) + + def test_length(self): + assert len(BETA(_A, _B, 5)) == N + + def test_same_series(self): + # Beta of x vs x = 1.0 (regression of itself) + result = BETA(_A, _A, timeperiod=5) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_finite_after_warmup(self): + result = BETA(_A, _B, timeperiod=5) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_matches_naive_beta(self): + expected = _naive_beta(_A, _B, timeperiod=5) + result = BETA(_A, _B, timeperiod=5) + np.testing.assert_allclose(result, expected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# CORREL +# --------------------------------------------------------------------------- + + +class TestCOREL: + def test_self_correlation_is_one(self): + result = CORREL(_A, _A, timeperiod=10) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 1.0, atol=1e-10) + + def test_opposite_correlation_is_minus_one(self): + arr = np.arange(1.0, 11.0) + result = CORREL(arr, arr[::-1], timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, -1.0, atol=1e-10) + + def test_range(self): + result = CORREL(_A, _B, timeperiod=10) + valid = result[~np.isnan(result)] + assert np.all(valid >= -1 - 1e-10) and np.all(valid <= 1 + 1e-10) + + def test_length(self): + assert len(CORREL(_A, _B, 10)) == N + + def test_matches_naive_correlation(self): + expected = _naive_correl(_A, _B, timeperiod=10) + result = CORREL(_A, _B, timeperiod=10) + np.testing.assert_allclose(result, expected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# TSF +# --------------------------------------------------------------------------- + + +class TestTSF: + def test_perfect_line(self): + arr = np.arange(1.0, 10.0) + result = TSF(arr, timeperiod=3) + # TSF(3) on [1,2,...] = linear forecast one period ahead + # Over window [1,2,3]: slope=1, intercept=0 → forecast at bar 2+1=3 → TSF[2]=4 + np.testing.assert_allclose(result[2], 4.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 5.0, rtol=1e-10) + + def test_nan_warmup(self): + result = TSF(_A, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_length(self): + assert len(TSF(_A, 14)) == N + + def test_matches_naive_tsf(self): + expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0) + result = TSF(_A, timeperiod=14) + np.testing.assert_allclose(result, expected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# DTW — Dynamic Time Warping +# --------------------------------------------------------------------------- + +dtai = pytest.importorskip("dtaidistance", reason="dtaidistance not installed") + +_DTW_RNG = np.random.default_rng(42) + + +class TestDTW: + # --- Validation against dtaidistance (SOTA reference) --- + + def test_distance_matches_dtaidistance_random(self): + """Core correctness: our distance == dtaidistance on 20 random pairs.""" + for _ in range(20): + n = int(_DTW_RNG.integers(5, 50)) + a = _DTW_RNG.random(n) + b = _DTW_RNG.random(n) + expected = dtai.dtw.distance(a, b) + actual = DTW_DISTANCE(a, b) + np.testing.assert_allclose( + actual, expected, rtol=1e-9, err_msg=f"Mismatch on series length {n}" + ) + + def test_distance_matches_dtaidistance_unequal_length(self): + """Handles unequal-length series correctly.""" + for _ in range(10): + a = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30))) + b = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30))) + expected = dtai.dtw.distance(a, b) + actual = DTW_DISTANCE(a, b) + np.testing.assert_allclose(actual, expected, rtol=1e-9) + + def test_path_distance_matches_dtaidistance(self): + """DTW() path variant: returned distance matches dtaidistance.""" + a = _DTW_RNG.random(20) + b = _DTW_RNG.random(25) + expected = dtai.dtw.distance(a, b) + dist, _ = DTW(a, b) + np.testing.assert_allclose(dist, expected, rtol=1e-9) + + def test_path_matches_dtaidistance_warping_path(self): + """Warping path matches dtaidistance.dtw.warping_path() on same-length series.""" + for _ in range(10): + n = int(_DTW_RNG.integers(5, 20)) + a = _DTW_RNG.random(n) + b = _DTW_RNG.random(n) + expected_path = dtai.dtw.warping_path(a, b) + _, actual_path = DTW(a, b) + actual_pairs = [tuple(int(x) for x in row) for row in actual_path] + assert actual_pairs == expected_path, ( + f"Path mismatch for n={n}:\n ours={actual_pairs}\n dtai={expected_path}" + ) + + def test_window_constrained_matches_dtaidistance(self): + """Sakoe-Chiba window matches dtaidistance window parameter.""" + a = _DTW_RNG.random(30) + b = _DTW_RNG.random(30) + for w in [3, 8, 15]: + expected = dtai.dtw.distance(a, b, window=w) + actual = DTW_DISTANCE(a, b, window=w) + np.testing.assert_allclose( + actual, expected, rtol=1e-9, err_msg=f"Mismatch at window={w}" + ) + + def test_batch_matches_dtaidistance(self): + """BATCH_DTW matches calling dtaidistance per-row.""" + ref = _DTW_RNG.random(20) + matrix = _DTW_RNG.random((8, 20)) + batch_result = BATCH_DTW(matrix, ref) + for i in range(8): + expected = dtai.dtw.distance(matrix[i], ref) + np.testing.assert_allclose( + batch_result[i], + expected, + rtol=1e-9, + err_msg=f"Batch mismatch at row {i}", + ) + + # --- Mathematical properties --- + + def test_identical_distance_is_zero(self): + a = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + dist, _ = DTW(a, a) + assert dist == pytest.approx(0.0, abs=1e-10) + + def test_symmetry(self): + a, b = _DTW_RNG.random(20), _DTW_RNG.random(20) + assert DTW_DISTANCE(a, b) == pytest.approx(DTW_DISTANCE(b, a), rel=1e-10) + + def test_triangle_inequality(self): + a, b, c = _DTW_RNG.random(15), _DTW_RNG.random(15), _DTW_RNG.random(15) + assert DTW_DISTANCE(a, c) <= DTW_DISTANCE(a, b) + DTW_DISTANCE(b, c) + 1e-9 + + # --- Known hardcoded values --- + + def test_known_shifted_series(self): + # [0,1,2] vs [1,2,3]: optimal path (0,0)→(1,0)→(2,1)→(2,2) + # Squared costs: 1+0+0+1=2, sqrt(2). Verified against dtaidistance. + a = np.array([0.0, 1.0, 2.0]) + b = np.array([1.0, 2.0, 3.0]) + np.testing.assert_allclose(DTW_DISTANCE(a, b), np.sqrt(2.0), rtol=1e-9) + + def test_known_single_element(self): + # sqrt((3-7)^2) = sqrt(16) = 4.0 + np.testing.assert_allclose( + DTW_DISTANCE(np.array([3.0]), np.array([7.0])), 4.0, rtol=1e-9 + ) + + def test_known_constant_series(self): + assert DTW_DISTANCE(np.full(10, 5.0), np.full(10, 5.0)) == pytest.approx( + 0.0, abs=1e-12 + ) + + # --- Path structural guarantees --- + + def test_path_starts_at_origin(self): + _, path = DTW(_DTW_RNG.random(10), _DTW_RNG.random(10)) + assert tuple(int(x) for x in path[0]) == (0, 0) + + def test_path_ends_at_corner(self): + _, path = DTW(_DTW_RNG.random(7), _DTW_RNG.random(9)) + assert tuple(int(x) for x in path[-1]) == (6, 8) + + def test_path_is_monotone(self): + _, path = DTW(_DTW_RNG.random(20), _DTW_RNG.random(20)) + for k in range(1, len(path)): + assert path[k][0] >= path[k - 1][0] + assert path[k][1] >= path[k - 1][1] + + def test_path_steps_unit_size(self): + _, path = DTW(_DTW_RNG.random(15), _DTW_RNG.random(12)) + for k in range(1, len(path)): + di = int(path[k][0]) - int(path[k - 1][0]) + dj = int(path[k][1]) - int(path[k - 1][1]) + assert di in (0, 1) and dj in (0, 1) + assert not (di == 0 and dj == 0) + + # --- DTW_DISTANCE == DTW distance --- + + def test_distance_only_matches_full(self): + a, b = _DTW_RNG.random(25), _DTW_RNG.random(25) + d_full, _ = DTW(a, b) + np.testing.assert_allclose(DTW_DISTANCE(a, b), d_full, rtol=1e-10) + + # --- Batch --- + + def test_batch_single_row(self): + ref = np.array([1.0, 2.0, 3.0]) + result = BATCH_DTW(np.array([[1.0, 2.0, 3.0]]), ref) + assert result[0] == pytest.approx(0.0, abs=1e-10) + + def test_batch_matches_single_calls(self): + ref = _DTW_RNG.random(20) + matrix = _DTW_RNG.random((8, 20)) + batch = BATCH_DTW(matrix, ref) + for i in range(8): + np.testing.assert_allclose( + batch[i], DTW_DISTANCE(matrix[i], ref), rtol=1e-10 + ) + + # --- Edge cases --- + + def test_empty_series_raises(self): + with pytest.raises((ValueError, Exception)): + DTW(np.array([]), np.array([1.0, 2.0])) + + def test_window_constrained_ge_unconstrained(self): + a, b = _DTW_RNG.random(20), _DTW_RNG.random(20) + d_full = DTW_DISTANCE(a, b) + d_narrow = DTW_DISTANCE(a, b, window=2) + assert d_narrow >= d_full - 1e-9 diff --git a/ferro-ta-main/tests/unit/indicators/test_volatility.py b/ferro-ta-main/tests/unit/indicators/test_volatility.py new file mode 100644 index 0000000..5ff81c2 --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_volatility.py @@ -0,0 +1,125 @@ +"""Unit tests for ferro_ta.indicators.volatility""" + +import numpy as np + +from ferro_ta.indicators.volatility import ATR, NATR, TRANGE + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(3) +N = 100 +_CLOSE = 100 + 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)) + +# Simple 5-bar data with constant range +SMALL_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) +SMALL_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) +SMALL_C = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) + + +# --------------------------------------------------------------------------- +# TRANGE +# --------------------------------------------------------------------------- + + +class TestTRANGE: + def test_known_values_constant_range(self): + result = TRANGE(SMALL_H, SMALL_L, SMALL_C) + # First bar: only high-low = 3 (no prior close) + np.testing.assert_allclose(result[0], 3.0, rtol=1e-10) + np.testing.assert_allclose(result[1], 3.0, rtol=1e-10) + + def test_no_nan(self): + result = TRANGE(SMALL_H, SMALL_L, SMALL_C) + assert np.all(np.isfinite(result)) + + def test_always_positive(self): + result = TRANGE(_HIGH, _LOW, _CLOSE) + assert np.all(result > 0) + + def test_length(self): + assert len(TRANGE(_HIGH, _LOW, _CLOSE)) == N + + def test_formula_first_bar(self): + h = np.array([15.0, 16.0, 17.0]) + l = np.array([10.0, 11.0, 12.0]) + c = np.array([13.0, 14.0, 15.0]) + result = TRANGE(h, l, c) + # bar 0: TRANGE = h[0] - l[0] = 5 + np.testing.assert_allclose(result[0], 5.0, rtol=1e-10) + # bar 1: max(h[1]-l[1], |h[1]-c[0]|, |l[1]-c[0]|) + # = max(5, |16-13|, |11-13|) = max(5, 3, 2) = 5 + np.testing.assert_allclose(result[1], 5.0, rtol=1e-10) + + def test_with_gap(self): + # Gap up: prev close=10, curr high=20, curr low=15 + h = np.array([10.0, 20.0]) + l = np.array([8.0, 15.0]) + c = np.array([10.0, 18.0]) + result = TRANGE(h, l, c) + # bar 1: max(20-15, |20-10|, |15-10|) = max(5, 10, 5) = 10 + np.testing.assert_allclose(result[1], 10.0, rtol=1e-10) + + +# --------------------------------------------------------------------------- +# ATR +# --------------------------------------------------------------------------- + + +class TestATR: + def test_timeperiod_1_equals_trange(self): + atr = ATR(SMALL_H, SMALL_L, SMALL_C, timeperiod=1) + trange = TRANGE(SMALL_H, SMALL_L, SMALL_C) + # ATR(1) first bar is NaN, subsequent equal TRANGE + np.testing.assert_allclose(atr[1:], trange[1:], rtol=1e-10) + + def test_nan_warmup(self): + result = ATR(_HIGH, _LOW, _CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:14])) + + def test_length(self): + assert len(ATR(_HIGH, _LOW, _CLOSE, 14)) == N + + def test_always_positive(self): + result = ATR(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid > 0) + + def test_constant_range_converges(self): + # Constant TRANGE=3 → ATR should converge to 3 + h = np.full(100, 12.0) + np.arange(100) * 0.0 + l = np.full(100, 9.0) + np.arange(100) * 0.0 + c = np.full(100, 11.0) + np.arange(100) * 0.0 + result = ATR(h, l, c, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid[-1], 3.0, atol=0.01) + + +# --------------------------------------------------------------------------- +# NATR +# --------------------------------------------------------------------------- + + +class TestNATR: + def test_nan_warmup(self): + result = NATR(_HIGH, _LOW, _CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:14])) + + def test_length(self): + assert len(NATR(_HIGH, _LOW, _CLOSE, 14)) == N + + def test_positive(self): + result = NATR(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid > 0) + + def test_relation_to_atr(self): + # NATR = ATR / close * 100 + atr = ATR(_HIGH, _LOW, _CLOSE, 14) + natr = NATR(_HIGH, _LOW, _CLOSE, 14) + valid = ~np.isnan(atr) & ~np.isnan(natr) + expected = atr[valid] / _CLOSE[valid] * 100 + np.testing.assert_allclose(natr[valid], expected, rtol=1e-5) diff --git a/ferro-ta-main/tests/unit/indicators/test_volume.py b/ferro-ta-main/tests/unit/indicators/test_volume.py new file mode 100644 index 0000000..12b18c5 --- /dev/null +++ b/ferro-ta-main/tests/unit/indicators/test_volume.py @@ -0,0 +1,118 @@ +"""Unit tests for ferro_ta.indicators.volume""" + +import numpy as np + +from ferro_ta.indicators.volume import AD, ADOSC, OBV + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(5) +N = 100 +_CLOSE = 100 + 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)) +_VOL = RNG.uniform(1000, 5000, N) + +SMALL_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) +SMALL_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) +SMALL_C = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) +SMALL_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0]) + + +# --------------------------------------------------------------------------- +# OBV +# --------------------------------------------------------------------------- + + +class TestOBV: + def test_known_values_rising(self): + # Rising close: OBV accumulates all volume + c = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + v = np.array([1000.0, 1000.0, 1000.0, 1000.0, 1000.0]) + result = OBV(c, v) + np.testing.assert_allclose(result[0], 0.0, atol=1e-10) + np.testing.assert_allclose(result[1], 1000.0, atol=1e-10) + np.testing.assert_allclose(result[4], 4000.0, atol=1e-10) + + def test_known_values_falling(self): + c = np.array([14.0, 13.0, 12.0, 11.0, 10.0]) + v = np.array([1000.0, 1000.0, 1000.0, 1000.0, 1000.0]) + result = OBV(c, v) + np.testing.assert_allclose(result[0], 0.0, atol=1e-10) + np.testing.assert_allclose(result[1], -1000.0, atol=1e-10) + np.testing.assert_allclose(result[4], -4000.0, atol=1e-10) + + def test_unchanged_price_no_change(self): + c = np.array([10.0, 10.0, 10.0]) + v = np.array([500.0, 500.0, 500.0]) + result = OBV(c, v) + np.testing.assert_allclose(result, [0.0, 0.0, 0.0], atol=1e-10) + + def test_no_nan(self): + result = OBV(SMALL_C, SMALL_V) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(OBV(_CLOSE, _VOL)) == N + + def test_starts_zero(self): + result = OBV(_CLOSE, _VOL) + np.testing.assert_allclose(result[0], 0.0, atol=1e-10) + + +# --------------------------------------------------------------------------- +# AD +# --------------------------------------------------------------------------- + + +class TestAD: + def test_known_formula(self): + # AD = cumsum(CLV * volume) + # CLV = ((close - low) - (high - close)) / (high - low) + h = np.array([15.0]) + l = np.array([10.0]) + c = np.array([12.0]) + v = np.array([1000.0]) + clv = ((12 - 10) - (15 - 12)) / (15 - 10) # (2 - 3) / 5 = -0.2 + expected = clv * 1000.0 + result = AD(h, l, c, v) + np.testing.assert_allclose(result[0], expected, rtol=1e-10) + + def test_monotone_rising_positive(self): + # High CLV on rising data → AD should be non-negative cumulatively + result = AD(SMALL_H, SMALL_L, SMALL_C, SMALL_V) + assert np.all(np.isfinite(result)) + + def test_no_nan(self): + result = AD(_HIGH, _LOW, _CLOSE, _VOL) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(AD(_HIGH, _LOW, _CLOSE, _VOL)) == N + + +# --------------------------------------------------------------------------- +# ADOSC +# --------------------------------------------------------------------------- + + +class TestADOSC: + def test_nan_warmup(self): + result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10) + assert np.all(np.isnan(result[:9])) + + def test_length(self): + assert len(ADOSC(_HIGH, _LOW, _CLOSE, _VOL, 3, 10)) == N + + def test_finite_after_warmup(self): + result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_known_values(self): + result = ADOSC(SMALL_H, SMALL_L, SMALL_C, SMALL_V, fastperiod=2, slowperiod=3) + valid = result[~np.isnan(result)] + assert len(valid) > 0 + assert np.all(np.isfinite(valid)) diff --git a/ferro-ta-main/tests/unit/streaming/__init__.py b/ferro-ta-main/tests/unit/streaming/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ferro-ta-main/tests/unit/streaming/test_streaming.py b/ferro-ta-main/tests/unit/streaming/test_streaming.py new file mode 100644 index 0000000..841ef38 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/tests/unit/test_coverage.py b/ferro-ta-main/tests/unit/test_coverage.py new file mode 100644 index 0000000..bb95bd5 --- /dev/null +++ b/ferro-ta-main/tests/unit/test_coverage.py @@ -0,0 +1,2513 @@ +"""Additional tests to improve code coverage across all ferro_ta modules. + +These tests target previously uncovered code paths including: +- Error-handling branches in indicator wrappers (except ValueError blocks) +- Utility helpers with untested code paths +- Module-level imports and edge cases +""" + +from __future__ import annotations + +import sys +from unittest.mock import patch + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +CLOSE = np.cumprod(1 + RNG.normal(0, 0.01, 200)) * 100.0 +HIGH = CLOSE * RNG.uniform(1.001, 1.01, 200) +LOW = CLOSE * RNG.uniform(0.99, 0.999, 200) +OPEN = CLOSE * RNG.uniform(0.999, 1.001, 200) +VOLUME = RNG.uniform(500, 5000, 200) + +# 2D array — triggers ValueError("Input must be a 1-D array") in _to_f64 +_2D = np.array([[1.0, 2.0, 3.0, 4.0, 5.0]]) + + +# =========================================================================== +# _utils.py — uncovered paths +# =========================================================================== + + +class TestUtilsUncovered: + """Cover uncovered paths in _utils.py.""" + + def test_to_f64_pandas_series(self): + """Line 43: pandas Series path in _to_f64.""" + pd = pytest.importorskip("pandas") + from ferro_ta._utils import _to_f64 + + s = pd.Series([1.0, 2.0, 3.0]) + result = _to_f64(s) + assert result.dtype == np.float64 + np.testing.assert_array_equal(result, [1.0, 2.0, 3.0]) + + def test_to_f64_polars_series(self): + """Lines 47-50: polars Series path in _to_f64.""" + pl = pytest.importorskip("polars") + from ferro_ta._utils import _to_f64 + + s = pl.Series("close", [1.0, 2.0, 3.0]) + result = _to_f64(s) + assert result.dtype == np.float64 + assert len(result) == 3 + + def test_get_ohlcv_non_dataframe_raises(self): + """Lines 100-101: get_ohlcv raises TypeError for non-DataFrame.""" + pytest.importorskip("pandas") + from ferro_ta._utils import get_ohlcv + + with pytest.raises(TypeError, match="pandas.DataFrame"): + get_ohlcv({"not": "a dataframe"}) + + def test_get_ohlcv_missing_column_raises(self): + """Line 104: get_ohlcv raises KeyError for missing column.""" + pd = pytest.importorskip("pandas") + from ferro_ta._utils import get_ohlcv + + df = pd.DataFrame({"open": [1.0], "high": [1.1], "low": [0.9], "close": [1.0]}) + # Pass a custom close_col that doesn't exist → raises KeyError + with pytest.raises(KeyError): + get_ohlcv(df, close_col="nonexistent_col") + + def test_get_ohlcv_none_volume_col(self): + """Lines 108-110: volume_col=None returns NaN array.""" + pd = pytest.importorskip("pandas") + from ferro_ta._utils import get_ohlcv + + df = pd.DataFrame( + { + "open": [1.0, 2.0], + "high": [1.1, 2.1], + "low": [0.9, 1.9], + "close": [1.0, 2.0], + } + ) + o, h, l, c, v = get_ohlcv( + df, + open_col="open", + high_col="high", + low_col="low", + close_col="close", + volume_col=None, + ) + assert np.all(np.isnan(v)) + + def test_pandas_wrap_dataframe_single_col(self): + """Lines 160-161: pandas_wrap handles single-column DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta import SMA + + df = pd.DataFrame({"close": CLOSE[:50]}) + result = SMA(df, timeperiod=5) + assert isinstance(result, (pd.Series, np.ndarray)) + + def test_pandas_wrap_tuple_output(self): + """Lines 172-178: pandas_wrap wraps tuple output in Series.""" + pd = pytest.importorskip("pandas") + from ferro_ta import BBANDS + + s = pd.Series(CLOSE[:50]) + upper, mid, lower = BBANDS(s, timeperiod=5) + assert isinstance(upper, pd.Series) + assert isinstance(mid, pd.Series) + assert isinstance(lower, pd.Series) + + def test_polars_wrap_tuple_output(self): + """Lines 233-234: polars_wrap wraps tuple output.""" + pl = pytest.importorskip("polars") + from ferro_ta import BBANDS + + s = pl.Series("close", CLOSE[:50].tolist()) + upper, mid, lower = BBANDS(s, timeperiod=5) + assert isinstance(upper, pl.Series) + + def test_polars_wrap_single_output(self): + """Line 246: polars_wrap wraps single ndarray output.""" + pl = pytest.importorskip("polars") + from ferro_ta import SMA + + s = pl.Series("close", CLOSE[:50].tolist()) + result = SMA(s, timeperiod=5) + assert isinstance(result, pl.Series) + + def test_to_f64_polars_cast_exception_fallback(self): + """Lines 49-50: polars Series with cast exception falls back to to_list.""" + from ferro_ta._utils import _to_f64 + + # Create a mock that simulates a polars-like Series: + # - no 'to_numpy' attribute (so the pandas path is skipped) + # - has 'to_list()' method + # - type name is 'Series' (polars Series match condition) + # - has 'cast()' that raises an exception + class _FakePolars: + def to_list(self): + return [1.0, 2.0, 3.0] + + def cast(self, *args, **kwargs): + raise Exception("cast failed") + + _FakePolars.__name__ = "Series" + + result = _to_f64(_FakePolars()) + assert result.dtype == np.float64 + np.testing.assert_array_equal(result, [1.0, 2.0, 3.0]) + + +# =========================================================================== +# _binding.py — full coverage +# =========================================================================== + + +class TestBindingCall: + """Cover binding_call in _binding.py.""" + + def test_basic_call_success(self): + """Basic binding_call with timeperiod validation.""" + from ferro_ta._ferro_ta import sma as _sma + + from ferro_ta._binding import binding_call + + result = binding_call( + _sma, + array_params=["close"], + timeperiod_param="timeperiod", + close=CLOSE, + timeperiod=5, + ) + assert len(result) == len(CLOSE) + + def test_timeperiod_validation_raises(self): + """binding_call raises FerroTAValueError for invalid timeperiod.""" + from ferro_ta._ferro_ta import sma as _sma + + from ferro_ta._binding import binding_call + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError): + binding_call( + _sma, + array_params=["close"], + timeperiod_param="timeperiod", + close=CLOSE, + timeperiod=0, + ) + + def test_equal_length_validation_raises(self): + """binding_call raises FerroTAInputError for mismatched lengths.""" + from ferro_ta._ferro_ta import atr as _atr + + from ferro_ta._binding import binding_call + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + binding_call( + _atr, + array_params=["high", "low", "close"], + equal_length_groups=[["high", "low", "close"]], + timeperiod_param="timeperiod", + high=HIGH, + low=LOW[:10], # mismatched + close=CLOSE, + timeperiod=14, + ) + + def test_rust_error_normalization(self): + """binding_call normalizes Rust ValueError via _normalize_rust_error.""" + from ferro_ta._binding import binding_call + from ferro_ta.core.exceptions import FerroTAValueError + + # Use a function that will raise ValueError from Rust (invalid timeperiod) + def bad_fn(*args, **kwargs): + raise ValueError("timeperiod must be >= 1") + + with pytest.raises(FerroTAValueError): + binding_call(bad_fn, array_params=["close"], close=CLOSE) + + def test_no_timeperiod_param(self): + """binding_call without timeperiod_param skips timeperiod check.""" + from ferro_ta._ferro_ta import sma as _sma + + from ferro_ta._binding import binding_call + + result = binding_call(_sma, array_params=["close"], close=CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + +# =========================================================================== +# raw.py — import coverage +# =========================================================================== + + +class TestRawImport: + """Import ferro_ta.raw to cover the re-export statements.""" + + def test_raw_import(self): + """Importing ferro_ta.raw covers the re-export lines.""" + import ferro_ta.core.raw as raw + + assert hasattr(raw, "sma") + assert hasattr(raw, "ema") + assert hasattr(raw, "rsi") + assert hasattr(raw, "batch_sma") + + def test_raw_sma(self): + """ferro_ta.raw.sma works directly.""" + from ferro_ta.core.raw import sma + + result = sma(CLOSE, 5) + assert len(result) == len(CLOSE) + + +# =========================================================================== +# mcp/__main__.py — import coverage +# =========================================================================== + + +class TestMCPMain: + """Import ferro_ta.mcp.__main__ to cover module-level lines.""" + + def test_main_import(self): + """Importing mcp.__main__ covers lines 3-4.""" + import importlib + + mod = importlib.import_module("ferro_ta.mcp.__main__") + assert hasattr(mod, "run_server") + + +# =========================================================================== +# cycle.py — error-path coverage +# =========================================================================== + + +class TestCycleErrorPaths: + """Cover except ValueError branches in cycle.py.""" + + def test_ht_trendline_2d_raises(self): + from ferro_ta import HT_TRENDLINE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_TRENDLINE(_2D) + + def test_ht_dcperiod_2d_raises(self): + from ferro_ta import HT_DCPERIOD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_DCPERIOD(_2D) + + def test_ht_dcphase_2d_raises(self): + from ferro_ta import HT_DCPHASE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_DCPHASE(_2D) + + def test_ht_phasor_2d_raises(self): + from ferro_ta import HT_PHASOR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_PHASOR(_2D) + + def test_ht_sine_2d_raises(self): + from ferro_ta import HT_SINE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_SINE(_2D) + + def test_ht_trendmode_2d_raises(self): + from ferro_ta import HT_TRENDMODE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_TRENDMODE(_2D) + + +# =========================================================================== +# statistic.py — error-path coverage +# =========================================================================== + + +class TestStatisticErrorPaths: + """Cover except ValueError branches in statistic.py.""" + + def test_stddev_2d_raises(self): + from ferro_ta import STDDEV + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + STDDEV(_2D) + + def test_var_2d_raises(self): + from ferro_ta import VAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + VAR(_2D) + + def test_linearreg_2d_raises(self): + from ferro_ta import LINEARREG + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + LINEARREG(_2D) + + def test_linearreg_slope_2d_raises(self): + from ferro_ta import LINEARREG_SLOPE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + LINEARREG_SLOPE(_2D) + + def test_linearreg_intercept_2d_raises(self): + from ferro_ta import LINEARREG_INTERCEPT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + LINEARREG_INTERCEPT(_2D) + + def test_linearreg_angle_2d_raises(self): + from ferro_ta import LINEARREG_ANGLE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + LINEARREG_ANGLE(_2D) + + def test_tsf_2d_raises(self): + from ferro_ta import TSF + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + TSF(_2D) + + def test_beta_2d_raises(self): + from ferro_ta import BETA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + BETA(_2D, CLOSE) + + def test_correl_2d_raises(self): + from ferro_ta import CORREL + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CORREL(_2D, CLOSE) + + +# =========================================================================== +# overlap.py — error-path coverage +# =========================================================================== + + +class TestOverlapErrorPaths: + """Cover except ValueError branches in overlap.py.""" + + def test_sma_2d_raises(self): + from ferro_ta import SMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SMA(_2D) + + def test_ema_2d_raises(self): + from ferro_ta import EMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + EMA(_2D) + + def test_wma_2d_raises(self): + from ferro_ta import WMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + WMA(_2D) + + def test_trima_2d_raises(self): + from ferro_ta import TRIMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + TRIMA(_2D) + + def test_kama_2d_raises(self): + from ferro_ta import KAMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + KAMA(_2D) + + def test_t3_2d_raises(self): + from ferro_ta import T3 + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + T3(_2D) + + def test_bbands_2d_raises(self): + from ferro_ta import BBANDS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + BBANDS(_2D) + + def test_macd_2d_raises(self): + from ferro_ta import MACD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MACD(_2D) + + def test_macdfix_2d_raises(self): + from ferro_ta import MACDFIX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MACDFIX(_2D) + + def test_sar_2d_raises(self): + from ferro_ta import SAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SAR(_2D, LOW) + + def test_midpoint_2d_raises(self): + from ferro_ta import MIDPOINT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MIDPOINT(_2D) + + def test_midprice_2d_raises(self): + from ferro_ta import MIDPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MIDPRICE(_2D, LOW) + + def test_mama_2d_raises(self): + from ferro_ta import MAMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MAMA(_2D) + + def test_sarext_2d_raises(self): + from ferro_ta import SAREXT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SAREXT(_2D, LOW) + + def test_macdext_2d_raises(self): + from ferro_ta import MACDEXT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MACDEXT(_2D) + + +# =========================================================================== +# momentum.py — error-path coverage +# =========================================================================== + + +class TestMomentumErrorPaths: + """Cover except ValueError branches in momentum.py.""" + + def test_rsi_2d_raises(self): + from ferro_ta import RSI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + RSI(_2D) + + def test_mom_2d_raises(self): + from ferro_ta import MOM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MOM(_2D) + + def test_roc_2d_raises(self): + from ferro_ta import ROC + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ROC(_2D) + + def test_rocp_2d_raises(self): + from ferro_ta import ROCP + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ROCP(_2D) + + def test_rocr_2d_raises(self): + from ferro_ta import ROCR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ROCR(_2D) + + def test_rocr100_2d_raises(self): + from ferro_ta import ROCR100 + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ROCR100(_2D) + + def test_willr_2d_raises(self): + from ferro_ta import WILLR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + WILLR(_2D, LOW, CLOSE) + + def test_adx_2d_raises(self): + from ferro_ta import ADX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ADX(_2D, LOW, CLOSE) + + def test_adxr_2d_raises(self): + from ferro_ta import ADXR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ADXR(_2D, LOW, CLOSE) + + def test_apo_2d_raises(self): + from ferro_ta import APO + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + APO(_2D) + + def test_ppo_2d_raises(self): + from ferro_ta import PPO + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + PPO(_2D) + + def test_cci_2d_raises(self): + from ferro_ta import CCI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CCI(_2D, LOW, CLOSE) + + def test_mfi_2d_raises(self): + from ferro_ta import MFI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MFI(_2D, LOW, CLOSE, VOLUME) + + def test_bop_2d_raises(self): + from ferro_ta import BOP + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + BOP(_2D, HIGH, LOW, CLOSE) + + def test_stochf_2d_raises(self): + from ferro_ta import STOCHF + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + STOCHF(_2D, LOW, CLOSE) + + def test_stoch_2d_raises(self): + from ferro_ta import STOCH + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + STOCH(_2D, LOW, CLOSE) + + def test_stochrsi_2d_raises(self): + from ferro_ta import STOCHRSI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + STOCHRSI(_2D) + + def test_ultosc_2d_raises(self): + from ferro_ta import ULTOSC + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ULTOSC(_2D, LOW, CLOSE) + + def test_dx_2d_raises(self): + from ferro_ta import DX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + DX(_2D, LOW, CLOSE) + + def test_plus_di_2d_raises(self): + from ferro_ta import PLUS_DI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + PLUS_DI(_2D, LOW, CLOSE) + + def test_minus_di_2d_raises(self): + from ferro_ta import MINUS_DI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MINUS_DI(_2D, LOW, CLOSE) + + def test_plus_dm_2d_raises(self): + from ferro_ta import PLUS_DM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + PLUS_DM(_2D, LOW) + + def test_minus_dm_2d_raises(self): + from ferro_ta import MINUS_DM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MINUS_DM(_2D, LOW) + + def test_cmo_2d_raises(self): + from ferro_ta import CMO + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CMO(_2D) + + def test_aroon_2d_raises(self): + from ferro_ta import AROON + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + AROON(_2D, LOW) + + def test_aroonosc_2d_raises(self): + from ferro_ta import AROONOSC + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + AROONOSC(_2D, LOW) + + def test_trix_2d_raises(self): + from ferro_ta import TRIX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + TRIX(_2D) + + +# =========================================================================== +# price_transform.py — error-path coverage +# =========================================================================== + + +class TestPriceTransformErrorPaths: + """Cover except ValueError branches in price_transform.py.""" + + def test_avgprice_2d_raises(self): + from ferro_ta import AVGPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + AVGPRICE(_2D, HIGH, LOW, CLOSE) + + def test_medprice_2d_raises(self): + from ferro_ta import MEDPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MEDPRICE(_2D, LOW) + + def test_typprice_2d_raises(self): + from ferro_ta import TYPPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + TYPPRICE(_2D, LOW, CLOSE) + + def test_wclprice_2d_raises(self): + from ferro_ta import WCLPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + WCLPRICE(_2D, LOW, CLOSE) + + +# =========================================================================== +# volume.py — error-path coverage +# =========================================================================== + + +class TestVolumeErrorPaths: + """Cover except ValueError branches in volume.py.""" + + def test_ad_2d_raises(self): + from ferro_ta import AD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + AD(_2D, LOW, CLOSE, VOLUME) + + def test_adosc_2d_raises(self): + from ferro_ta import ADOSC + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ADOSC(_2D, LOW, CLOSE, VOLUME) + + def test_obv_2d_raises(self): + from ferro_ta import OBV + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + OBV(_2D, VOLUME) + + +# =========================================================================== +# volatility.py — error-path coverage +# =========================================================================== + + +class TestVolatilityErrorPaths: + """Cover except ValueError branches in volatility.py.""" + + def test_atr_2d_raises(self): + from ferro_ta import ATR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ATR(_2D, LOW, CLOSE) + + def test_natr_2d_raises(self): + from ferro_ta import NATR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + NATR(_2D, LOW, CLOSE) + + +# =========================================================================== +# math_ops.py — error-path coverage +# =========================================================================== + + +class TestMathOpsErrorPaths: + """Cover except ValueError branches in math_ops.py.""" + + def test_add_2d_raises(self): + from ferro_ta import ADD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ADD(_2D, CLOSE) + + def test_sub_2d_raises(self): + from ferro_ta import SUB + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SUB(_2D, CLOSE) + + def test_mult_2d_raises(self): + from ferro_ta import MULT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MULT(_2D, CLOSE) + + def test_div_2d_raises(self): + from ferro_ta import DIV + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + DIV(_2D, CLOSE) + + def test_sum_2d_raises(self): + from ferro_ta import SUM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SUM(_2D) + + def test_max_2d_raises(self): + from ferro_ta import MAX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MAX(_2D) + + def test_min_2d_raises(self): + from ferro_ta import MIN + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MIN(_2D) + + def test_maxindex_2d_raises(self): + from ferro_ta import MAXINDEX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MAXINDEX(_2D) + + def test_minindex_2d_raises(self): + from ferro_ta import MININDEX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MININDEX(_2D) + + +# =========================================================================== +# pattern.py — error-path coverage (sample of CDL functions) +# =========================================================================== + + +class TestPatternErrorPaths: + """Cover except ValueError branches in pattern.py for CDL functions.""" + + def _ohlc(self): + return OPEN, HIGH, LOW, CLOSE + + def test_cdl2crows_2d_raises(self): + from ferro_ta import CDL2CROWS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL2CROWS(_2D, HIGH, LOW, CLOSE) + + def test_cdldoji_2d_raises(self): + from ferro_ta import CDLDOJI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLDOJI(_2D, HIGH, LOW, CLOSE) + + def test_cdl3blackcrows_2d_raises(self): + from ferro_ta import CDL3BLACKCROWS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3BLACKCROWS(_2D, HIGH, LOW, CLOSE) + + def test_cdl3inside_2d_raises(self): + from ferro_ta import CDL3INSIDE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3INSIDE(_2D, HIGH, LOW, CLOSE) + + def test_cdlengulfing_2d_raises(self): + from ferro_ta import CDLENGULFING + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLENGULFING(_2D, HIGH, LOW, CLOSE) + + def test_cdlhammer_2d_raises(self): + from ferro_ta import CDLHAMMER + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHAMMER(_2D, HIGH, LOW, CLOSE) + + def test_cdlmarubozu_2d_raises(self): + from ferro_ta import CDLMARUBOZU + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMARUBOZU(_2D, HIGH, LOW, CLOSE) + + def test_cdlmorningstar_2d_raises(self): + from ferro_ta import CDLMORNINGSTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMORNINGSTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdleveningstar_2d_raises(self): + from ferro_ta import CDLEVENINGSTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLEVENINGSTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlshootingstar_2d_raises(self): + from ferro_ta import CDLSHOOTINGSTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSHOOTINGSTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlharami_2d_raises(self): + from ferro_ta import CDLHARAMI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHARAMI(_2D, HIGH, LOW, CLOSE) + + def test_cdldojistar_2d_raises(self): + from ferro_ta import CDLDOJISTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLDOJISTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlspinningtop_2d_raises(self): + from ferro_ta import CDLSPINNINGTOP + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSPINNINGTOP(_2D, HIGH, LOW, CLOSE) + + def test_cdlkicking_2d_raises(self): + from ferro_ta import CDLKICKING + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLKICKING(_2D, HIGH, LOW, CLOSE) + + def test_cdlpiercing_2d_raises(self): + from ferro_ta import CDLPIERCING + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLPIERCING(_2D, HIGH, LOW, CLOSE) + + def test_cdl3whitesoldiers_2d_raises(self): + from ferro_ta import CDL3WHITESOLDIERS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3WHITESOLDIERS(_2D, HIGH, LOW, CLOSE) + + def test_cdl3outside_2d_raises(self): + from ferro_ta import CDL3OUTSIDE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3OUTSIDE(_2D, HIGH, LOW, CLOSE) + + def test_cdlmorningdojistar_2d_raises(self): + from ferro_ta import CDLMORNINGDOJISTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMORNINGDOJISTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdleveningdojistar_2d_raises(self): + from ferro_ta import CDLEVENINGDOJISTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLEVENINGDOJISTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlharamicross_2d_raises(self): + from ferro_ta import CDLHARAMICROSS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHARAMICROSS(_2D, HIGH, LOW, CLOSE) + + def test_cdl3linestrike_2d_raises(self): + from ferro_ta import CDL3LINESTRIKE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3LINESTRIKE(_2D, HIGH, LOW, CLOSE) + + def test_cdl3starsinsouth_2d_raises(self): + from ferro_ta import CDL3STARSINSOUTH + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3STARSINSOUTH(_2D, HIGH, LOW, CLOSE) + + def test_cdlabandonedbaby_2d_raises(self): + from ferro_ta import CDLABANDONEDBABY + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLABANDONEDBABY(_2D, HIGH, LOW, CLOSE) + + def test_cdladvanceblock_2d_raises(self): + from ferro_ta import CDLADVANCEBLOCK + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLADVANCEBLOCK(_2D, HIGH, LOW, CLOSE) + + def test_cdlbelthold_2d_raises(self): + from ferro_ta import CDLBELTHOLD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLBELTHOLD(_2D, HIGH, LOW, CLOSE) + + def test_cdlbreakaway_2d_raises(self): + from ferro_ta import CDLBREAKAWAY + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLBREAKAWAY(_2D, HIGH, LOW, CLOSE) + + def test_cdlclosingmarubozu_2d_raises(self): + from ferro_ta import CDLCLOSINGMARUBOZU + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLCLOSINGMARUBOZU(_2D, HIGH, LOW, CLOSE) + + def test_cdlconcealbabyswall_2d_raises(self): + from ferro_ta import CDLCONCEALBABYSWALL + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLCONCEALBABYSWALL(_2D, HIGH, LOW, CLOSE) + + def test_cdlcounterattack_2d_raises(self): + from ferro_ta import CDLCOUNTERATTACK + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLCOUNTERATTACK(_2D, HIGH, LOW, CLOSE) + + def test_cdldarkcloudcover_2d_raises(self): + from ferro_ta import CDLDARKCLOUDCOVER + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLDARKCLOUDCOVER(_2D, HIGH, LOW, CLOSE) + + def test_cdldragonflydoji_2d_raises(self): + from ferro_ta import CDLDRAGONFLYDOJI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLDRAGONFLYDOJI(_2D, HIGH, LOW, CLOSE) + + def test_cdlgapsidesidewhite_2d_raises(self): + from ferro_ta import CDLGAPSIDESIDEWHITE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLGAPSIDESIDEWHITE(_2D, HIGH, LOW, CLOSE) + + def test_cdlgravestonedoji_2d_raises(self): + from ferro_ta import CDLGRAVESTONEDOJI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLGRAVESTONEDOJI(_2D, HIGH, LOW, CLOSE) + + def test_cdlhangingman_2d_raises(self): + from ferro_ta import CDLHANGINGMAN + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHANGINGMAN(_2D, HIGH, LOW, CLOSE) + + def test_cdlhighwave_2d_raises(self): + from ferro_ta import CDLHIGHWAVE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHIGHWAVE(_2D, HIGH, LOW, CLOSE) + + def test_cdlhikkake_2d_raises(self): + from ferro_ta import CDLHIKKAKE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHIKKAKE(_2D, HIGH, LOW, CLOSE) + + def test_cdlhikkakemod_2d_raises(self): + from ferro_ta import CDLHIKKAKEMOD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHIKKAKEMOD(_2D, HIGH, LOW, CLOSE) + + def test_cdlhomingpigeon_2d_raises(self): + from ferro_ta import CDLHOMINGPIGEON + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHOMINGPIGEON(_2D, HIGH, LOW, CLOSE) + + def test_cdlidentical3crows_2d_raises(self): + from ferro_ta import CDLIDENTICAL3CROWS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLIDENTICAL3CROWS(_2D, HIGH, LOW, CLOSE) + + def test_cdlinneck_2d_raises(self): + from ferro_ta import CDLINNECK + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLINNECK(_2D, HIGH, LOW, CLOSE) + + def test_cdlinvertedhammer_2d_raises(self): + from ferro_ta import CDLINVERTEDHAMMER + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLINVERTEDHAMMER(_2D, HIGH, LOW, CLOSE) + + def test_cdlkickingbylength_2d_raises(self): + from ferro_ta import CDLKICKINGBYLENGTH + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLKICKINGBYLENGTH(_2D, HIGH, LOW, CLOSE) + + def test_cdlladderbottom_2d_raises(self): + from ferro_ta import CDLLADDERBOTTOM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLLADDERBOTTOM(_2D, HIGH, LOW, CLOSE) + + def test_cdllongleggeddoji_2d_raises(self): + from ferro_ta import CDLLONGLEGGEDDOJI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLLONGLEGGEDDOJI(_2D, HIGH, LOW, CLOSE) + + def test_cdllongline_2d_raises(self): + from ferro_ta import CDLLONGLINE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLLONGLINE(_2D, HIGH, LOW, CLOSE) + + def test_cdlmatchinglow_2d_raises(self): + from ferro_ta import CDLMATCHINGLOW + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMATCHINGLOW(_2D, HIGH, LOW, CLOSE) + + def test_cdlmathold_2d_raises(self): + from ferro_ta import CDLMATHOLD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMATHOLD(_2D, HIGH, LOW, CLOSE) + + def test_cdlonneck_2d_raises(self): + from ferro_ta import CDLONNECK + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLONNECK(_2D, HIGH, LOW, CLOSE) + + def test_cdlrickshawman_2d_raises(self): + from ferro_ta import CDLRICKSHAWMAN + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLRICKSHAWMAN(_2D, HIGH, LOW, CLOSE) + + def test_cdlrisefall3methods_2d_raises(self): + from ferro_ta import CDLRISEFALL3METHODS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLRISEFALL3METHODS(_2D, HIGH, LOW, CLOSE) + + def test_cdlseparatinglines_2d_raises(self): + from ferro_ta import CDLSEPARATINGLINES + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSEPARATINGLINES(_2D, HIGH, LOW, CLOSE) + + def test_cdlshortline_2d_raises(self): + from ferro_ta import CDLSHORTLINE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSHORTLINE(_2D, HIGH, LOW, CLOSE) + + def test_cdlstalledpattern_2d_raises(self): + from ferro_ta import CDLSTALLEDPATTERN + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSTALLEDPATTERN(_2D, HIGH, LOW, CLOSE) + + def test_cdlsticksandwich_2d_raises(self): + from ferro_ta import CDLSTICKSANDWICH + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSTICKSANDWICH(_2D, HIGH, LOW, CLOSE) + + def test_cdltakuri_2d_raises(self): + from ferro_ta import CDLTAKURI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLTAKURI(_2D, HIGH, LOW, CLOSE) + + def test_cdltasukigap_2d_raises(self): + from ferro_ta import CDLTASUKIGAP + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLTASUKIGAP(_2D, HIGH, LOW, CLOSE) + + def test_cdlthrusting_2d_raises(self): + from ferro_ta import CDLTHRUSTING + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLTHRUSTING(_2D, HIGH, LOW, CLOSE) + + def test_cdltristar_2d_raises(self): + from ferro_ta import CDLTRISTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLTRISTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlunique3river_2d_raises(self): + from ferro_ta import CDLUNIQUE3RIVER + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLUNIQUE3RIVER(_2D, HIGH, LOW, CLOSE) + + def test_cdlupsidegap2crows_2d_raises(self): + from ferro_ta import CDLUPSIDEGAP2CROWS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLUPSIDEGAP2CROWS(_2D, HIGH, LOW, CLOSE) + + def test_cdlxsidegap3methods_2d_raises(self): + from ferro_ta import CDLXSIDEGAP3METHODS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLXSIDEGAP3METHODS(_2D, HIGH, LOW, CLOSE) + + +# =========================================================================== +# exceptions.py — uncovered paths +# =========================================================================== + + +class TestExceptionsUncovered: + """Cover uncovered lines in exceptions.py.""" + + def test_check_equal_length_with_shape_attr(self): + """Line 107-108: check_equal_length with object having .shape attr.""" + from ferro_ta.core.exceptions import check_equal_length + + class FakeArr: + shape = (3,) + + arr = FakeArr() + # Should not raise when both have same length via shape attribute + check_equal_length(a=arr, b=arr) + + def test_check_equal_length_mismatched_shapes(self): + """Lines 110-114: check_equal_length raises with mismatched .shape.""" + from ferro_ta.core.exceptions import FerroTAInputError, check_equal_length + + class FakeArr: + def __init__(self, n): + self.shape = (n,) + + with pytest.raises(FerroTAInputError, match="same length"): + check_equal_length(a=FakeArr(3), b=FakeArr(5)) + + def test_check_min_length_with_shape_attr(self): + """Lines 167-168: check_min_length with .shape attribute.""" + from ferro_ta.core.exceptions import FerroTAInputError, check_min_length + + class FakeArr: + shape = (2,) + + arr = FakeArr() + # Should raise since len = 2 < min_len = 5 + with pytest.raises(FerroTAInputError, match="at least 5 elements"): + check_min_length(arr, 5, name="input") + + +# =========================================================================== +# extended.py — uncovered path +# =========================================================================== + + +class TestExtendedUncovered: + """Cover uncovered lines in extended.py.""" + + def test_vwap_negative_timeperiod_raises(self): + """Lines 107-109: VWAP raises FerroTAValueError for negative timeperiod.""" + from ferro_ta import VWAP + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 0"): + VWAP(HIGH, LOW, CLOSE, VOLUME, timeperiod=-1) + + +# =========================================================================== +# options.py — uncovered path +# =========================================================================== + + +class TestOptionsUncovered: + """Cover uncovered line in options.py.""" + + def test_validate_iv_2d_raises(self): + """Line 63: _validate_iv raises for 2D input.""" + from ferro_ta.analysis.options import iv_rank + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + iv_rank(np.array([[0.2, 0.3]]), window=5) + + +# =========================================================================== +# adapters.py — uncovered paths +# =========================================================================== + + +class TestAdaptersUncovered: + """Cover uncovered lines in adapters.py.""" + + def test_register_non_subclass_raises(self): + """Line 82: register_adapter raises TypeError for non-subclass.""" + from ferro_ta.data.adapters import register_adapter + + with pytest.raises(TypeError): + register_adapter("bad", int) + + def test_dataadapter_repr(self): + """Line 134: DataAdapter __repr__.""" + from ferro_ta.data.adapters import InMemoryAdapter + + adapter = InMemoryAdapter({"close": CLOSE}) + assert "InMemoryAdapter" in repr(adapter) + + def test_inmemory_adapter_fetch(self): + """Lines 263: InMemoryAdapter.fetch returns wrapped data.""" + from ferro_ta.data.adapters import InMemoryAdapter + + data = {"close": CLOSE, "high": HIGH} + adapter = InMemoryAdapter(data) + result = adapter.fetch() + assert result is data + + def test_csvadapter_repr(self): + """Line 225: CsvAdapter __repr__.""" + from ferro_ta.data.adapters import CsvAdapter + + adapter = CsvAdapter("/tmp/test.csv") + assert "/tmp/test.csv" in repr(adapter) + + def test_csvadapter_fetch_with_rename(self): + """Lines 200-221: CsvAdapter.fetch with column rename.""" + import csv + import os + import tempfile + + pytest.importorskip("pandas") + from ferro_ta.data.adapters import CsvAdapter + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".csv", delete=False, newline="" + ) as f: + writer = csv.writer(f) + writer.writerow(["Open", "High", "Low", "Close", "Volume"]) + for i in range(5): + writer.writerow([1.0, 1.1, 0.9, 1.0, 1000]) + fname = f.name + + try: + adapter = CsvAdapter( + fname, + open_col="Open", + high_col="High", + low_col="Low", + close_col="Close", + volume_col="Volume", + ) + df = adapter.fetch() + assert "close" in df.columns or "Close" in df.columns + finally: + os.unlink(fname) + + +# =========================================================================== +# aggregation.py — uncovered paths +# =========================================================================== + + +class TestAggregationUncovered: + """Cover uncovered lines in aggregation.py.""" + + def test_aggregate_ticks_no_pandas_fallback(self): + """Lines 194-195: aggregate_ticks without pandas returns dict.""" + from ferro_ta.data.aggregation import aggregate_ticks + + rng = np.random.default_rng(0) + n = 200 + ticks = { + "price": rng.uniform(99, 101, n), + "size": rng.uniform(1, 10, n), + } + bars = aggregate_ticks(ticks, rule="tick:50") + assert "close" in bars + + def test_tick_aggregator_repr(self): + """Line 233: TickAggregator __repr__.""" + from ferro_ta.data.aggregation import TickAggregator + + agg = TickAggregator(rule="tick:50") + assert "TickAggregator" in repr(agg) + assert "tick:50" in repr(agg) + + def test_aggregate_ticks_with_extra_timestamp(self): + """Lines 75-76, 80: aggregate_ticks with extra (timestamp) parameter.""" + from ferro_ta.data.aggregation import aggregate_ticks + + rng = np.random.default_rng(0) + n = 200 + ticks = { + "price": rng.uniform(99, 101, n), + "size": rng.uniform(1, 10, n), + "timestamp": np.arange(n, dtype=np.int64), + } + bars = aggregate_ticks(ticks, rule="tick:50") + assert "close" in bars + + def test_time_resample_missing_columns_raises(self): + """Lines 156-163: aggregate_ticks with time rule requires timestamp.""" + from ferro_ta.data.aggregation import aggregate_ticks + + # Time bars without timestamp should raise ValueError + rng = np.random.default_rng(0) + n = 200 + ticks = { + "price": rng.uniform(99, 101, n), + "size": rng.uniform(1, 10, n), + # no timestamp — time bars would require one + } + with pytest.raises(ValueError, match="timestamp"): + aggregate_ticks(ticks, rule="time:60") + + def test_time_resample_non_datetime_index_raises(self): + """Lines 155-162: aggregate_ticks with pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.data.aggregation import aggregate_ticks + + rng = np.random.default_rng(0) + n = 200 + df = pd.DataFrame( + { + "price": rng.uniform(99, 101, n), + "size": rng.uniform(1, 10, n), + } + ) + bars = aggregate_ticks(df, rule="tick:50") + assert "close" in bars + + +# =========================================================================== +# alerts.py — uncovered paths +# =========================================================================== + + +class TestAlertsUncovered: + """Cover uncovered lines in alerts.py.""" + + def test_alert_event_repr(self): + """Line 166: AlertEvent __repr__.""" + from ferro_ta.tools.alerts import AlertEvent + + ev = AlertEvent("test_cond", bar_index=5, value=75.0, payload={"key": "val"}) + r = repr(ev) + assert "test_cond" in r + assert "5" in r + + def test_dispatch_with_callback(self): + """Lines 407-408: _dispatch invokes callback.""" + from ferro_ta.tools.alerts import AlertEvent, AlertManager + + events_received = [] + + def cb(ev): + events_received.append(ev) + + ev = AlertEvent("cond", bar_index=1, value=50.0) + AlertManager._dispatch(ev, cb, None) + assert len(events_received) == 1 + + def test_dispatch_callback_exception_logged(self): + """Lines 407-408: _dispatch swallows callback exceptions.""" + from ferro_ta.tools.alerts import AlertEvent, AlertManager + + def bad_cb(ev): + raise RuntimeError("callback error") + + ev = AlertEvent("cond", bar_index=1, value=50.0) + # Should not raise — exception is logged/swallowed + AlertManager._dispatch(ev, bad_cb, None) + + def test_dispatch_webhook_post(self): + """Lines 410-411: _dispatch calls _post_webhook when url is set.""" + from ferro_ta.tools.alerts import AlertEvent, AlertManager + + ev = AlertEvent("cond", bar_index=1, value=50.0) + # Use an invalid URL to trigger the except branch in _post_webhook + AlertManager._dispatch(ev, None, "http://localhost:99999/webhook") + + def test_post_webhook_failure_logged(self): + """Lines 416-430: _post_webhook logs failure on connection error.""" + from ferro_ta.tools.alerts import AlertManager + + # Should not raise — failure is logged + AlertManager._post_webhook("http://localhost:99999/x", {"key": "val"}) + + def test_alert_manager_force_live_with_callback(self): + """Lines 388: AlertManager.run_backtest with force_live dispatches events.""" + from ferro_ta.tools.alerts import AlertManager + + received = [] + + def cb(ev): + received.append(ev) + + series = np.array([20.0, 25.0, 30.0, 35.0, 28.0]) + mgr = AlertManager(symbol="TEST") + mgr.add_threshold_condition( + "rsi_ob", series, level=29.0, direction=1, callback=cb + ) + events = mgr.run_backtest(force_live=True) + # Events should have been dispatched + assert len(received) > 0 or len(events) >= 0 + + +# =========================================================================== +# attribution.py — uncovered paths +# =========================================================================== + + +class TestAttributionUncovered: + """Cover uncovered lines in attribution.py.""" + + def test_trade_stats_repr(self): + """Line 105: TradeStats __repr__.""" + from ferro_ta.analysis.attribution import TradeStats + + ts = TradeStats( + win_rate=0.55, + avg_win=120.0, + avg_loss=-80.0, + profit_factor=1.8, + avg_hold_bars=5.0, + n_trades=20, + ) + r = repr(ts) + assert "n_trades=20" in r + assert "win_rate" in r + + def test_monthly_contribution_without_timestamps(self): + """Lines 282-307: attribution_by_month without timestamps.""" + from ferro_ta.analysis.attribution import attribution_by_month + + ret = RNG.normal(0, 0.01, 100) + result = attribution_by_month(ret) + assert isinstance(result, dict) + assert len(result) > 0 + + def test_monthly_contribution_with_timestamps(self): + """Lines 267-281: attribution_by_month with timestamps.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.attribution import attribution_by_month + + n = 60 + ret = RNG.normal(0, 0.01, n) + ts = pd.date_range("2023-01-01", periods=n, freq="D") + timestamps = ts.view("int64") + result = attribution_by_month(ret, timestamps=timestamps) + assert isinstance(result, dict) + + def test_factor_attribution_basic(self): + """Lines 290-305: attribution_by_signal returns factor exposures.""" + from ferro_ta.analysis.attribution import attribution_by_signal + + n = 100 + portfolio_ret = RNG.normal(0, 0.01, n) + signal = (RNG.normal(0, 1, n) > 0).astype(np.float64) + result = attribution_by_signal(portfolio_ret, signal) + assert isinstance(result, dict) + + +# =========================================================================== +# backtest.py — uncovered paths +# =========================================================================== + + +class TestBacktestUncovered: + """Cover uncovered lines in backtest.py.""" + + def test_sma_cross_fast_gt_slow_raises(self): + """Lines 188-190: sma_crossover_strategy raises when fast >= slow.""" + from ferro_ta.analysis.backtest import sma_crossover_strategy + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError): + sma_crossover_strategy(CLOSE, fast=26, slow=12) + + def test_sma_cross_fast_zero_raises(self): + """Line 188: sma_crossover_strategy raises for fast < 1.""" + from ferro_ta.analysis.backtest import sma_crossover_strategy + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError): + sma_crossover_strategy(CLOSE, fast=0, slow=20) + + def test_macd_cross_fast_ge_slow_raises(self): + """Line 232: macd_crossover_strategy raises when fast >= slow.""" + from ferro_ta.analysis.backtest import macd_crossover_strategy + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError): + macd_crossover_strategy(CLOSE, fastperiod=26, slowperiod=12) + + def test_backtest_unknown_string_strategy_raises(self): + """Line 338: backtest raises for unknown strategy string.""" + from ferro_ta.analysis.backtest import backtest + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError, match="strategy must be"): + backtest(CLOSE, strategy=123) + + +# =========================================================================== +# batch.py — uncovered paths +# =========================================================================== + + +class TestBatchUncovered: + """Cover uncovered lines in batch.py.""" + + def test_batch_sma_1d_fallback(self): + """Line 95: batch_sma with 1D input calls single-series SMA.""" + from ferro_ta.data.batch import batch_sma + + result = batch_sma(CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_batch_ema_1d_fallback(self): + """Line 137: batch_ema with 1D input.""" + from ferro_ta.data.batch import batch_ema + + result = batch_ema(CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_batch_rsi_1d_fallback(self): + """Line 160: batch_rsi with 1D input.""" + from ferro_ta.data.batch import batch_rsi + + result = batch_rsi(CLOSE, timeperiod=14) + assert len(result) == len(CLOSE) + + def test_batch_apply_1d(self): + """Line 185: batch_apply with 1D input.""" + from ferro_ta import SMA + from ferro_ta.data.batch import batch_apply + + result = batch_apply(CLOSE, SMA, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_batch_apply_3d_raises(self): + """Line 162, 187: batch_apply with 3D input raises ValueError.""" + from ferro_ta import SMA + from ferro_ta.data.batch import batch_apply + + arr_3d = np.ones((10, 3, 2)) + with pytest.raises(ValueError, match="1-D or 2-D"): + batch_apply(arr_3d, SMA, timeperiod=5) + + +# =========================================================================== +# chunked.py — uncovered paths +# =========================================================================== + + +class TestChunkedUncovered: + """Cover uncovered lines in chunked.py.""" + + def test_chunk_apply_empty_series(self): + """Line 190: chunk_apply returns empty for zero-length input.""" + from ferro_ta import SMA + from ferro_ta.data.chunked import chunk_apply + + result = chunk_apply(SMA, np.array([]), timeperiod=5) + assert len(result) == 0 + + def test_chunk_apply_small_series_no_chunks(self): + """Lines 194-195: chunk_apply falls back when ranges is empty.""" + from ferro_ta import SMA + from ferro_ta.data.chunked import chunk_apply + + # chunk_size larger than series → make_chunk_ranges returns [] → fallback + result = chunk_apply( + SMA, CLOSE[:10], chunk_size=5000, overlap=100, timeperiod=3 + ) + assert len(result) == 10 + + +# =========================================================================== +# crypto.py — uncovered paths +# =========================================================================== + + +class TestCryptoUncovered: + """Cover uncovered lines in crypto.py.""" + + def test_funding_rate_pnl_with_tuple_ohlcv(self): + """Lines 67-107: funding_pnl basic usage.""" + from ferro_ta.analysis.crypto import funding_pnl + + n = 96 + position_size = np.ones(n) + funding = np.full(n, 0.0001) + + result = funding_pnl(position_size, funding) + assert len(result) == n + + +# =========================================================================== +# dsl.py — uncovered paths +# =========================================================================== + + +class TestDSLUncovered: + """Cover uncovered lines in dsl.py.""" + + def test_expr_not_implemented(self): + """Line 71: _Expr.eval raises NotImplementedError.""" + from ferro_ta.tools.dsl import _Expr + + expr = _Expr() + with pytest.raises(NotImplementedError): + expr.eval({}) + + def test_price_ref_missing_raises(self): + """Line 80: _PriceRef raises ValueError for missing series.""" + from ferro_ta.tools.dsl import _PriceRef + + ref = _PriceRef("volume") + with pytest.raises(ValueError, match="not found"): + ref.eval({"close": CLOSE}) + + def test_indicator_call_missing_close_raises(self): + """Line 101: _IndicatorCall raises ValueError when close missing.""" + from ferro_ta.tools.dsl import _IndicatorCall + + call = _IndicatorCall("SMA", [14]) + with pytest.raises(ValueError, match="'close' series is required"): + call.eval({}) + + def test_indicator_call_unknown_indicator_raises(self): + """Lines 125-128: _IndicatorCall raises for unknown indicator name.""" + from ferro_ta.tools.dsl import _IndicatorCall + + call = _IndicatorCall("NONEXISTENT_INDICATOR_XYZ", [14]) + with pytest.raises((ValueError, Exception)): + call.eval({"close": CLOSE}) + + def test_crossover_below_expr(self): + """Lines 191-203: _CrossFunc evaluates 'below' direction.""" + from ferro_ta.tools.dsl import _CrossFunc, _Expr + + class ConstArr(_Expr): + def __init__(self, arr): + self._arr = arr + + def eval(self, ctx): + return self._arr + + fast_arr = np.array([15.0, 15.0, 12.0, 11.0]) + slow_arr = np.array([13.0, 13.0, 13.0, 13.0]) + + crossover = _CrossFunc("below", ConstArr(fast_arr), ConstArr(slow_arr)) + result = crossover.eval({}) + assert result[2] == 1 + + def test_dsl_parse_and_run(self): + """Lines 119, 123-124, 126: DSL parse and run with indicators.""" + from ferro_ta.tools.dsl import evaluate + + ctx = { + "close": CLOSE, + "high": HIGH, + "low": LOW, + } + result = evaluate("SMA(14)", ctx) + assert isinstance(result, np.ndarray) + + def test_dsl_comparison_operators(self): + """Lines 270, 279: DSL comparison operators.""" + from ferro_ta.tools.dsl import evaluate + + ctx = {"close": CLOSE} + # Expression with comparison + result = evaluate("RSI(14) < 40", ctx) + assert isinstance(result, np.ndarray) + + +# =========================================================================== +# features.py — uncovered paths +# =========================================================================== + + +class TestFeaturesUncovered: + """Cover uncovered lines in features.py.""" + + def test_feature_matrix_missing_close_raises(self): + """Line 115: feature_matrix raises when close col missing.""" + from ferro_ta.analysis.features import feature_matrix + + with pytest.raises(ValueError, match="close column"): + feature_matrix({"high": HIGH}, [("SMA", {"timeperiod": 5})]) + + def test_feature_matrix_multi_output_with_index(self): + """Lines 152-165: feature_matrix with tuple output and out_key.""" + from ferro_ta.analysis.features import feature_matrix + + ohlcv = {"close": CLOSE, "high": HIGH, "low": LOW, "volume": VOLUME} + fm = feature_matrix( + ohlcv, + [("BBANDS", {"timeperiod": 5}, 0)], # out_key=0 → BBANDS_0 + ) + assert isinstance(fm, dict) or hasattr(fm, "columns") + + def test_feature_matrix_nan_policy_drop(self): + """Lines 183-194: feature_matrix with nan_policy='drop'.""" + from ferro_ta.analysis.features import feature_matrix + + ohlcv = {"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]} + fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 5})], nan_policy="drop") + # Result should have fewer rows than input (NaNs dropped) + if hasattr(fm, "__len__"): + assert len(fm) <= 30 + + def test_feature_matrix_nan_policy_fill(self): + """Lines 204-222: feature_matrix with nan_policy='fill'.""" + from ferro_ta.analysis.features import feature_matrix + + ohlcv = {"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]} + fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 5})], nan_policy="fill") + assert fm is not None + + def test_feature_matrix_pandas_dataframe_input(self): + """Lines 100-103: feature_matrix with pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.features import feature_matrix + + df = pd.DataFrame({"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]}) + fm = feature_matrix(df, [("SMA", {"timeperiod": 5})]) + assert isinstance(fm, pd.DataFrame) + + +# =========================================================================== +# gpu.py — uncovered paths (mock CuPy) +# =========================================================================== + + +class TestGPUUncovered: + """Cover uncovered lines in gpu.py using mocked CuPy.""" + + def test_sma_gpu_no_cupy_falls_back(self): + """Line 42: sma falls back to CPU when CuPy not available.""" + from ferro_ta.tools.gpu import sma as gpu_sma + + result = gpu_sma(CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_ema_gpu_no_cupy_falls_back(self): + """Lines 55-57: ema falls back to CPU when CuPy not available.""" + from ferro_ta.tools.gpu import ema as gpu_ema + + result = gpu_ema(CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_rsi_gpu_no_cupy_falls_back(self): + """Line 62: rsi falls back to CPU when CuPy not available.""" + from ferro_ta.tools.gpu import rsi as gpu_rsi + + result = gpu_rsi(CLOSE, timeperiod=14) + assert len(result) == len(CLOSE) + + def test_sma_gpu_with_mock_cupy(self): + """_is_torch and _to_cpu helpers: NumPy arrays are not torch, _to_cpu passes through.""" + import ferro_ta.tools.gpu as gpu_module + + arr = np.asarray(CLOSE[:30], dtype=np.float64) + assert not gpu_module._is_torch(arr) + result = gpu_module._to_cpu(arr) + np.testing.assert_array_equal(result, arr) + + def test_ema_gpu_with_mock_cupy(self): + """Lines 138-178: public sma/ema/rsi fallback produces correct shapes.""" + import ferro_ta.tools.gpu as gpu_module + + arr = np.asarray(CLOSE[:50], dtype=np.float64) + # All three public functions should fall back to CPU + sma_result = gpu_module.sma(arr, timeperiod=5) + ema_result = gpu_module.ema(arr, timeperiod=5) + rsi_result = gpu_module.rsi(arr, timeperiod=14) + assert len(sma_result) == len(arr) + assert len(ema_result) == len(arr) + assert len(rsi_result) == len(arr) + + def test_rsi_gpu_with_mock_cupy(self): + """gpu.py __all__ list and module attributes (_TORCH_AVAILABLE).""" + import ferro_ta.tools.gpu as gpu_module + + assert hasattr(gpu_module, "sma") + assert hasattr(gpu_module, "ema") + assert hasattr(gpu_module, "rsi") + assert hasattr(gpu_module, "_TORCH_AVAILABLE") + + +# =========================================================================== +# viz.py — uncovered paths (mock matplotlib/plotly) +# =========================================================================== + + +class TestVizUncovered: + """Cover uncovered lines in viz.py using mocked backends.""" + + def test_plot_dict_input(self): + """Lines 146-154: _extract_close_volume with dict input.""" + from ferro_ta.tools.viz import _extract_close_volume + + ohlcv = {"close": CLOSE, "volume": VOLUME} + close, vol = _extract_close_volume(ohlcv, "close", "volume") + np.testing.assert_array_equal(close, CLOSE) + + def test_plot_dict_no_volume(self): + """_extract_close_volume returns None for missing volume key.""" + from ferro_ta.tools.viz import _extract_close_volume + + ohlcv = {"close": CLOSE} + close, vol = _extract_close_volume(ohlcv, "close", "volume") + assert vol is None + + def test_plot_array_input(self): + """_extract_close_volume with plain array input.""" + from ferro_ta.tools.viz import _extract_close_volume + + close, vol = _extract_close_volume(CLOSE, "close", "volume") + np.testing.assert_array_equal(close, CLOSE) + assert vol is None + + def test_n_subplots_calculation(self): + """Lines 167-176: _n_subplots helper.""" + from ferro_ta.tools.viz import _n_subplots + + assert _n_subplots(None, None) == 1 + assert _n_subplots(None, VOLUME) == 2 + assert _n_subplots({"RSI": CLOSE}, None) == 2 + assert _n_subplots({"RSI": CLOSE}, VOLUME) == 3 + + def test_plot_matplotlib_no_matplotlib_raises(self): + """Lines 194-200: _plot_matplotlib raises ImportError without matplotlib.""" + from ferro_ta.tools.viz import _plot_matplotlib + + with patch.dict( + sys.modules, + { + "matplotlib": None, + "matplotlib.pyplot": None, + "matplotlib.gridspec": None, + }, + ): + with pytest.raises((ImportError, Exception)): + _plot_matplotlib( + CLOSE, + None, + None, + title=None, + figsize=None, + savefig=None, + show=False, + ) + + def test_plot_plotly_no_plotly_raises(self): + """Lines 262-268: _plot_plotly raises ImportError without plotly.""" + from ferro_ta.tools.viz import _plot_plotly + + with patch.dict( + sys.modules, + {"plotly": None, "plotly.graph_objects": None, "plotly.subplots": None}, + ): + with pytest.raises((ImportError, Exception)): + _plot_plotly( + CLOSE, + None, + None, + title=None, + figsize=None, + savefig=None, + show=False, + ) + + def test_plot_unknown_backend_raises(self): + """Line 116-119: plot raises ValueError for unknown backend.""" + from ferro_ta.tools.viz import plot + + with pytest.raises(ValueError, match="Unknown backend"): + plot({"close": CLOSE}, backend="unknown_backend") + + def test_plot_matplotlib_backend(self): + """Lines 106, 194-244: plot with matplotlib backend.""" + matplotlib = pytest.importorskip("matplotlib") + matplotlib.use("Agg") # non-interactive backend + + from ferro_ta import RSI, SMA + from ferro_ta.tools.viz import plot + + ohlcv = {"close": CLOSE[:50], "volume": VOLUME[:50]} + fig = plot( + ohlcv, + indicators={ + "SMA(10)": SMA(CLOSE[:50], timeperiod=10), + "RSI(14)": RSI(CLOSE[:50], timeperiod=14), + }, + backend="matplotlib", + show=False, + volume=True, + title="Test", + ) + assert fig is not None + + def test_plot_pandas_dataframe_input(self): + """Lines 146-154: _extract_close_volume with pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.tools.viz import _extract_close_volume + + df = pd.DataFrame({"close": CLOSE[:10], "volume": VOLUME[:10]}) + close, vol = _extract_close_volume(df, "close", "volume") + assert len(close) == 10 + + +# =========================================================================== +# dashboard.py — uncovered paths +# =========================================================================== + + +class TestDashboardUncovered: + """Cover uncovered lines in dashboard.py.""" + + def test_indicator_widget_no_ipywidgets_raises(self): + """Lines 96-132: indicator_widget raises ImportError without ipywidgets.""" + from ferro_ta.tools.dashboard import indicator_widget + + with patch.dict( + sys.modules, + {"ipywidgets": None, "matplotlib": None, "matplotlib.pyplot": None}, + ): + with pytest.raises((ImportError, Exception)): + indicator_widget(CLOSE, lambda c, **kw: c, "timeperiod", range(5, 15)) + + def test_backtest_widget_no_ipywidgets_raises(self): + """Lines 160-203: backtest_widget raises ImportError without ipywidgets.""" + from ferro_ta.tools.dashboard import backtest_widget + + with patch.dict( + sys.modules, + {"ipywidgets": None, "matplotlib": None, "matplotlib.pyplot": None}, + ): + with pytest.raises((ImportError, Exception)): + backtest_widget(CLOSE) + + def test_streamlit_app_no_streamlit_raises(self): + """Lines 240-336: streamlit_app raises ImportError without streamlit.""" + from ferro_ta.tools.dashboard import streamlit_app + + with patch.dict(sys.modules, {"streamlit": None}): + with pytest.raises((ImportError, Exception)): + streamlit_app() + + +# =========================================================================== +# regime.py — uncovered paths +# =========================================================================== + + +class TestRegimeUncovered: + """Cover uncovered lines in regime.py.""" + + def test_detect_regime_with_dataframe_ohlcv(self): + """Lines 249-256: regime accepts pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.regime import regime + + df = pd.DataFrame( + { + "open": OPEN, + "high": HIGH, + "low": LOW, + "close": CLOSE, + "volume": VOLUME, + } + ) + result = regime(df) + assert isinstance(result, np.ndarray) + + def test_detect_regime_with_tuple_ohlcv(self): + """Lines 254-256: regime with tuple ohlcv.""" + from ferro_ta.analysis.regime import regime + + ohlcv = (OPEN, HIGH, LOW, CLOSE, VOLUME) + result = regime(ohlcv) + assert isinstance(result, np.ndarray) + + +# =========================================================================== +# resampling.py — uncovered paths +# =========================================================================== + + +class TestResamplingUncovered: + """Cover uncovered lines in resampling.py.""" + + def test_resample_ohlcv_missing_columns_raises(self): + """Lines 114-115: resample raises for missing columns.""" + pd = pytest.importorskip("pandas") + from ferro_ta.data.resampling import resample + + df = pd.DataFrame( + {"close": [1.0, 2.0]}, + index=pd.to_datetime(["2024-01-01", "2024-01-02"]), + ) + with pytest.raises((ValueError, KeyError)): + resample(df, rule="1D") + + def test_resample_ohlcv_non_datetime_index_raises(self): + """Lines 123-128: resample raises for non-DatetimeIndex.""" + pd = pytest.importorskip("pandas") + from ferro_ta.data.resampling import resample + + df = pd.DataFrame( + { + "open": [1.0, 2.0], + "high": [1.1, 2.1], + "low": [0.9, 1.9], + "close": [1.0, 2.0], + "volume": [1000.0, 2000.0], + } + ) + with pytest.raises(ValueError, match="DatetimeIndex"): + resample(df, rule="1D") + + def test_multi_timeframe_no_pandas_fallback(self): + """Line 198-199: multi_timeframe with pandas DataFrame.""" + from ferro_ta.data.resampling import multi_timeframe + + pd_module = pytest.importorskip("pandas") + # With pandas available, test basic usage + n = 100 + df = pd_module.DataFrame( + { + "open": OPEN[:n], + "high": HIGH[:n], + "low": LOW[:n], + "close": CLOSE[:n], + "volume": VOLUME[:n], + }, + index=pd_module.date_range("2024-01-01", periods=n, freq="1h"), + ) + result = multi_timeframe(df, rules=["4h"]) + assert "4h" in result + + def test_ohlcv_resampler_repr(self): + """Line 276: volume_bars and resample function.""" + from ferro_ta.data.resampling import resample + + pd_module = pytest.importorskip("pandas") + n = 60 + df = pd_module.DataFrame( + { + "open": OPEN[:n], + "high": HIGH[:n], + "low": LOW[:n], + "close": CLOSE[:n], + "volume": VOLUME[:n], + }, + index=pd_module.date_range("2024-01-01", periods=n, freq="5min"), + ) + result = resample(df, rule="15min") + assert len(result) < n + + +# =========================================================================== +# signals.py — uncovered paths +# =========================================================================== + + +class TestSignalsUncovered: + """Cover uncovered lines in signals.py.""" + + def test_compose_signals_pandas_dataframe(self): + """Lines 115-123: compose with pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.signals import compose + + n = 50 + signals_df = pd.DataFrame( + { + "sig1": RNG.choice([-1, 0, 1], n).astype(np.float64), + "sig2": RNG.choice([-1, 0, 1], n).astype(np.float64), + } + ) + result = compose(signals_df, method="mean") + assert len(result) == n + + def test_screen_symbols_pandas_series(self): + """Lines 196-197: screen with pandas Series.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.signals import screen + + scores = pd.Series({"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9}) + result = screen(scores, top_n=2) + assert len(result) == 2 + + def test_screen_symbols_above_filter(self): + """Lines 223-224: screen with above filter.""" + from ferro_ta.analysis.signals import screen + + scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9} + result = screen(scores, above=0.7) + assert "GOOG" not in result + assert "AAPL" in result + + def test_screen_symbols_below_filter(self): + """Lines 225-227: screen with below filter.""" + from ferro_ta.analysis.signals import screen + + scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9} + result = screen(scores, below=0.7) + assert "GOOG" in result + assert "MSFT" not in result + + def test_screen_symbols_list_input(self): + """Lines 202-210: screen with list input.""" + from ferro_ta.analysis.signals import screen + + scores = [0.8, 0.5, 0.9] + result = screen(scores, top_n=2) + assert len(result) == 2 + + def test_compose_signals_rank_method(self): + """Line 126: compose with rank method.""" + from ferro_ta.analysis.signals import compose + + n = 50 + signals = np.column_stack( + [ + RNG.choice([-1, 0, 1], n).astype(np.float64), + RNG.choice([-1, 0, 1], n).astype(np.float64), + ] + ) + result = compose(signals, method="rank") + assert len(result) == n + + +# =========================================================================== +# pipeline.py — uncovered paths +# =========================================================================== + + +class TestPipelineUncovered: + """Cover uncovered lines in pipeline.py.""" + + def test_add_non_callable_raises(self): + """Line 170: Pipeline.add raises TypeError for non-callable.""" + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + with pytest.raises(TypeError, match="func must be callable"): + p.add("bad_step", "not_a_function") + + def test_add_duplicate_name_raises(self): + """Lines 179-183: Pipeline.add raises ValueError for duplicate name.""" + from ferro_ta import SMA + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + p.add("sma", SMA, timeperiod=5) + with pytest.raises(ValueError, match="already exists"): + p.add("sma", SMA, timeperiod=10) + + def test_add_duplicate_output_key_raises(self): + """Lines 176-177: Pipeline.add raises ValueError for duplicate output key.""" + from ferro_ta import BBANDS + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + p.add("bands", BBANDS, timeperiod=5, output_keys=["upper", "mid", "lower"]) + with pytest.raises(ValueError, match="Duplicate output key"): + p.add("bands2", BBANDS, timeperiod=10, output_keys=["upper", "x", "y"]) + + def test_remove_missing_step_raises(self): + """Line 210: Pipeline.remove raises KeyError for missing step.""" + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + with pytest.raises(KeyError, match="No step named"): + p.remove("nonexistent") + + def test_pipeline_run_with_tuple_output_and_output_keys(self): + """Lines 267-277: Pipeline.run handles tuple output with output_keys.""" + from ferro_ta import BBANDS + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + p.add("bands", BBANDS, timeperiod=5, output_keys=["upper", "mid", "lower"]) + result = p.run(CLOSE) + assert "upper" in result + assert "mid" in result + assert "lower" in result + + def test_pipeline_run_output_keys_length_mismatch_raises(self): + """Lines 269-272: Pipeline.run raises ValueError for output_keys length mismatch.""" + from ferro_ta import BBANDS + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + p.add("bands", BBANDS, timeperiod=5, output_keys=["upper"]) # BBANDS returns 3 + with pytest.raises(ValueError, match="output_keys has"): + p.run(CLOSE) + + def test_make_pipeline(self): + """Lines 291, 300-301: make_pipeline convenience factory.""" + from ferro_ta import RSI, SMA + from ferro_ta.tools.pipeline import make_pipeline + + p = make_pipeline( + sma=(SMA, {"timeperiod": 10}), + rsi=(RSI, {"timeperiod": 14}), + ) + result = p.run(CLOSE) + assert "sma" in result + assert "rsi" in result + + +# =========================================================================== +# portfolio.py — uncovered paths +# =========================================================================== + + +class TestPortfolioUncovered: + """Cover uncovered lines in portfolio.py.""" + + def test_correlation_matrix_pandas_returns(self): + """Lines 88-95: correlation_matrix with pandas DataFrame returns.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.portfolio import correlation_matrix + + n = 100 + df = pd.DataFrame( + { + "AAPL": RNG.normal(0, 0.01, n), + "GOOG": RNG.normal(0, 0.01, n), + } + ) + result = correlation_matrix(df) + assert isinstance(result, pd.DataFrame) + assert result.shape == (2, 2) + + def test_portfolio_beta_basic(self): + """Lines 164-195: portfolio.beta returns scalar or array.""" + from ferro_ta.analysis.portfolio import beta + + n = 100 + asset_ret = RNG.normal(0, 0.01, n) + bench_ret = RNG.normal(0, 0.01, n) + result = beta(asset_ret, bench_ret) + assert isinstance(result, (float, np.floating)) + + +# =========================================================================== +# tools.py — uncovered paths +# =========================================================================== + + +class TestToolsUncovered: + """Cover uncovered lines in tools.py.""" + + def test_call_indicator_multi_output_returns_dict(self): + """Line 129: compute_indicator returns dict for multi-output indicators.""" + from ferro_ta.tools import compute_indicator + + result = compute_indicator("BBANDS", CLOSE, timeperiod=5) + assert isinstance(result, dict) + assert "upper" in result + + def test_describe_indicator_unknown_raises(self): + """Line 273: describe_indicator raises for unknown name.""" + from ferro_ta.tools import describe_indicator + + with pytest.raises(Exception): + describe_indicator("NONEXISTENT_INDICATOR_XYZ") + + def test_describe_indicator_known(self): + """describe_indicator returns description for known indicator.""" + from ferro_ta.tools import describe_indicator + + result = describe_indicator("SMA") + assert isinstance(result, str) + assert len(result) > 0 + + +# =========================================================================== +# workflow.py — uncovered paths +# =========================================================================== + + +class TestWorkflowUncovered: + """Cover uncovered lines in workflow.py.""" + + def test_workflow_with_multi_output_indicator_alert(self): + """Lines 230, 233: workflow.run with alert on dict output (skip silently).""" + from ferro_ta.tools.workflow import Workflow + + wf = Workflow() + wf.add_indicator("bb", "BBANDS", timeperiod=5) + # Add an alert on a multi-output indicator key (should be skipped silently) + wf.add_alert("bb", level=CLOSE.mean(), direction=1) + result = wf.run(CLOSE) + assert "bb" in result diff --git a/ferro-ta-main/tests/unit/test_data_pipeline.py b/ferro-ta-main/tests/unit/test_data_pipeline.py new file mode 100644 index 0000000..cbc8f7c --- /dev/null +++ b/ferro-ta-main/tests/unit/test_data_pipeline.py @@ -0,0 +1,777 @@ +"""Tests for resampling, tick aggregation, DSL, signals, +portfolio analytics, cross-asset analytics, feature matrix, viz, and adapters. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Synthetic helpers +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(2024) + + +def _make_ohlcv(n: int = 100): + """Return (open, high, low, close, volume) as numpy arrays.""" + close = np.cumprod(1 + RNG.normal(0, 0.01, n)) * 100.0 + open_ = close * RNG.uniform(0.995, 1.005, n) + high = np.maximum(close, open_) + RNG.uniform(0, 0.5, n) + low = np.minimum(close, open_) - RNG.uniform(0, 0.5, n) + volume = RNG.uniform(500, 5000, n) + return open_, high, low, close, volume + + +def _make_ticks(n: int = 500): + price = 100.0 + np.cumsum(RNG.normal(0, 0.05, n)) + size = RNG.uniform(10, 100, n) + return price, size + + +# --------------------------------------------------------------------------- +# Resampling +# --------------------------------------------------------------------------- + + +class TestVolumeBarResampling: + """Rust-backed volume_bars function.""" + + def test_returns_five_arrays(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(100) + bars = volume_bars((o, h, l, c, v), volume_threshold=2000) + assert len(bars) == 5 + assert all(isinstance(b, np.ndarray) for b in bars) + + def test_volume_bars_reduce_length(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(200) + bars = volume_bars((o, h, l, c, v), volume_threshold=5000) + # Output should have fewer bars than input + assert len(bars[0]) < 200 + + def test_each_bar_high_ge_low(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(100) + ro, rh, rl, rc, rv = volume_bars((o, h, l, c, v), volume_threshold=2000) + assert np.all(rh >= rl) + + def test_output_volume_ge_threshold(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(100) + threshold = 1500.0 + _, _, _, _, rv = volume_bars((o, h, l, c, v), volume_threshold=threshold) + # All but the last bar should satisfy the threshold + if len(rv) > 1: + assert np.all(rv[:-1] >= threshold) + + def test_invalid_threshold_raises(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(10) + with pytest.raises(Exception): + volume_bars((o, h, l, c, v), volume_threshold=-1) + + def test_ohlcv_agg_rust_function(self): + from ferro_ta._ferro_ta import ohlcv_agg + + o, h, l, c, v = _make_ohlcv(10) + labels = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2, 2], dtype=np.int64) + ro, rh, rl, rc, rv = ohlcv_agg(o, h, l, c, v, labels) + assert len(ro) == 3 + + def test_resample_with_pandas(self): + """Time-based resampling using pandas DatetimeIndex.""" + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta.data.resampling import resample + + idx = pd.date_range("2024-01-01", periods=60, freq="1min") + o, h, l, c, v = _make_ohlcv(60) + df = pd.DataFrame( + {"open": o, "high": h, "low": l, "close": c, "volume": v}, + index=idx, + ) + df5 = resample(df, "5min") + # 60 1-minute bars → 12 or 13 5-minute bars depending on pandas version/label + assert 11 <= len(df5) <= 13 + assert set(df5.columns) == {"open", "high", "low", "close", "volume"} + + def test_volume_bars_dataframe_return(self): + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(60) + df = pd.DataFrame({"open": o, "high": h, "low": l, "close": c, "volume": v}) + result = volume_bars(df, volume_threshold=3000) + assert isinstance(result, pd.DataFrame) + assert "close" in result.columns + + def test_multi_timeframe_returns_dict(self): + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta import RSI + from ferro_ta.data.resampling import multi_timeframe + + idx = pd.date_range("2024-01-01", periods=200, freq="1min") + o, h, l, c, v = _make_ohlcv(200) + df = pd.DataFrame( + {"open": o, "high": h, "low": l, "close": c, "volume": v}, + index=idx, + ) + result = multi_timeframe( + df, ["5min", "15min"], indicator=RSI, indicator_kwargs={"timeperiod": 14} + ) + assert sorted(result.keys()) == ["15min", "5min"] + for key, arr in result.items(): + assert isinstance(arr, np.ndarray) + + +# --------------------------------------------------------------------------- +# Tick aggregation +# --------------------------------------------------------------------------- + + +class TestTickAggregation: + """aggregate_ticks and TickAggregator.""" + + def test_tick_bars_dict_input(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(500) + result = aggregate_ticks({"price": price, "size": size}, rule="tick:50") + assert "open" in result + # 500 / 50 = 10 bars + assert len(result["open"]) == 10 + + def test_volume_bars_ticks(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(200) + result = aggregate_ticks({"price": price, "size": size}, rule="volume:500") + assert len(result["open"]) > 0 + + def test_time_bars_ticks(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(300) + ts = np.arange(300, dtype=np.float64) # 1 second intervals + result = aggregate_ticks( + {"timestamp": ts, "price": price, "size": size}, rule="time:60" + ) + # 300 seconds / 60 = 5 bars + assert len(result["open"]) == 5 + + def test_tick_aggregator_class(self): + from ferro_ta.data.aggregation import TickAggregator + + agg = TickAggregator(rule="tick:50") + price, size = _make_ticks(200) + result = agg.aggregate({"price": price, "size": size}) + assert len(result["open"]) == 4 # 200 / 50 = 4 + + def test_invalid_rule_raises(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(100) + with pytest.raises(ValueError, match="Invalid rule"): + aggregate_ticks({"price": price, "size": size}, rule="bad_rule") + + def test_unknown_bar_type_raises(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(100) + with pytest.raises(ValueError, match="Unknown bar type"): + aggregate_ticks({"price": price, "size": size}, rule="unknown:50") + + def test_tick_bars_indicator_pipeline(self): + """Full pipeline: ticks → bars → RSI.""" + from ferro_ta import RSI + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(1000) + bars = aggregate_ticks({"price": price, "size": size}, rule="tick:20") + close = np.asarray(bars["close"], dtype=np.float64) + rsi = RSI(close, timeperiod=14) + assert rsi.shape == close.shape + + def test_list_input(self): + from ferro_ta.data.aggregation import aggregate_ticks + + ticks = [(float(i), 100.0 + i * 0.01, 10.0) for i in range(100)] + result = aggregate_ticks(ticks, rule="tick:10") + assert len(result["open"]) == 10 + + +# --------------------------------------------------------------------------- +# Strategy DSL +# --------------------------------------------------------------------------- + + +class TestStrategyDSL: + def test_parse_simple_expression(self): + from ferro_ta.tools.dsl import parse_expression + + ast = parse_expression("RSI(14) < 30") + assert ast is not None + + def test_evaluate_returns_int_array(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + sig = evaluate("RSI(14) < 30", {"close": close}) + assert sig.dtype == np.int32 + assert sig.shape == (100,) + assert set(sig.tolist()).issubset({0, 1}) + + def test_evaluate_and_expression(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + sig = evaluate("RSI(14) < 70 and RSI(14) > 30", {"close": close}) + assert sig.shape == (100,) + + def test_evaluate_or_expression(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + sig = evaluate("RSI(14) < 30 or RSI(14) > 70", {"close": close}) + assert sig.shape == (100,) + + def test_evaluate_not_expression(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + sig = evaluate("not RSI(14) < 30", {"close": close}) + assert set(sig.tolist()).issubset({0, 1}) + + def test_strategy_class(self): + from ferro_ta.tools.dsl import Strategy + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + strat = Strategy("RSI(14) < 30") + sig = strat.evaluate({"close": close}) + assert sig.shape == (100,) + + def test_combined_close_sma_expression(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 60)) * 100 + sig = evaluate("close > SMA(20)", {"close": close}) + assert sig.shape == (60,) + + def test_invalid_expression_raises(self): + from ferro_ta.tools.dsl import parse_expression + + with pytest.raises(ValueError): + parse_expression("") + + def test_parse_expression_with_cross_above_placeholder(self): + """cross_above tokens parse without error.""" + from ferro_ta.tools.dsl import parse_expression + + ast = parse_expression("cross_above(close, SMA(20))") + assert ast is not None + + def test_backtest_with_dsl_signal(self): + """Combine DSL signal with the existing backtest module.""" + from ferro_ta.analysis.backtest import backtest + from ferro_ta.tools.dsl import Strategy + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + strat = Strategy("RSI(14) < 30") + strat.evaluate({"close": close}) # signal not fed to backtest in this test + # Manually feed signal to backtest + result = backtest(close, strategy="rsi_30_70") + assert result is not None + + +# --------------------------------------------------------------------------- +# Signal composition and screening +# --------------------------------------------------------------------------- + + +class TestSignalComposition: + def test_compose_weighted(self): + from ferro_ta.analysis.signals import compose + + sigs = RNG.standard_normal((50, 3)) + score = compose(sigs, weights=[0.5, 0.3, 0.2]) + assert score.shape == (50,) + + def test_compose_mean(self): + from ferro_ta.analysis.signals import compose + + sigs = np.ones((10, 4)) * 2.0 + score = compose(sigs, method="mean") + np.testing.assert_allclose(score, 2.0) + + def test_compose_rank(self): + from ferro_ta.analysis.signals import compose + + sigs = RNG.standard_normal((30, 3)) + score = compose(sigs, method="rank") + assert score.shape == (30,) + + def test_compose_rank_matches_manual_column_ranks(self): + from ferro_ta.analysis.signals import compose + + sigs = np.array( + [ + [3.0, 1.0], + [1.0, 2.0], + [2.0, 2.0], + ], + dtype=np.float64, + ) + score = compose(sigs, method="rank") + expected = np.array([4.0, 3.5, 4.5], dtype=np.float64) + np.testing.assert_allclose(score, expected) + + def test_compose_equal_weights_default(self): + from ferro_ta.analysis.signals import compose + + sigs = np.ones((5, 3)) + score = compose(sigs) # equal weight by default + np.testing.assert_allclose(score, 1.0) + + def test_screen_top_n(self): + from ferro_ta.analysis.signals import screen + + scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3} + result = screen(scores, top_n=2) + assert list(result.keys()) == ["MSFT", "AAPL"] + + def test_screen_bottom_n(self): + from ferro_ta.analysis.signals import screen + + scores = {"A": 3, "B": 1, "C": 2} + result = screen(scores, bottom_n=2) + assert list(result.keys()) == ["B", "C"] + + def test_screen_above_threshold(self): + from ferro_ta.analysis.signals import screen + + scores = {"A": 0.7, "B": 0.3, "C": 0.9} + result = screen(scores, above=0.5) + assert set(result.keys()) == {"A", "C"} + + def test_rank_signals(self): + from ferro_ta.analysis.signals import rank_signals + + x = np.array([3.0, 1.0, 2.0]) + r = rank_signals(x) + np.testing.assert_allclose(r, [3.0, 1.0, 2.0]) + + def test_rank_signals_ties(self): + from ferro_ta.analysis.signals import rank_signals + + x = np.array([1.0, 1.0, 3.0]) + r = rank_signals(x) + np.testing.assert_allclose(r[0], 1.5) + np.testing.assert_allclose(r[1], 1.5) + np.testing.assert_allclose(r[2], 3.0) + + def test_top_n_indices_rust(self): + from ferro_ta._ferro_ta import top_n_indices + + x = np.array([1.0, 5.0, 3.0, 7.0, 2.0]) + idx = top_n_indices(x, 2) + vals = sorted(x[i] for i in idx) + assert vals == [5.0, 7.0] + + +# --------------------------------------------------------------------------- +# Portfolio analytics +# --------------------------------------------------------------------------- + + +class TestPortfolioAnalytics: + def test_correlation_matrix_shape(self): + from ferro_ta.analysis.portfolio import correlation_matrix + + r = RNG.normal(0, 0.01, (100, 4)) + corr = correlation_matrix(r) + assert corr.shape == (4, 4) + + def test_correlation_matrix_diagonal_ones(self): + from ferro_ta.analysis.portfolio import correlation_matrix + + r = RNG.normal(0, 0.01, (100, 3)) + corr = correlation_matrix(r) + np.testing.assert_allclose(np.diag(corr), 1.0, atol=1e-10) + + def test_correlation_matrix_symmetric(self): + from ferro_ta.analysis.portfolio import correlation_matrix + + r = RNG.normal(0, 0.01, (80, 3)) + corr = correlation_matrix(r) + np.testing.assert_allclose(corr, corr.T, atol=1e-12) + + def test_portfolio_volatility_positive(self): + from ferro_ta.analysis.portfolio import portfolio_volatility + + r = RNG.normal(0, 0.01, (100, 3)) + vol = portfolio_volatility(r, weights=[1 / 3, 1 / 3, 1 / 3]) + assert vol > 0 + + def test_portfolio_volatility_annualise(self): + from ferro_ta.analysis.portfolio import portfolio_volatility + + r = RNG.normal(0, 0.01, (252, 1)) + vol_raw = portfolio_volatility(r, weights=[1.0]) + vol_ann = portfolio_volatility(r, weights=[1.0], annualise=252) + np.testing.assert_allclose(vol_ann, vol_raw * 252**0.5, rtol=1e-6) + + def test_beta_scalar(self): + from ferro_ta.analysis.portfolio import beta + + bench = RNG.normal(0, 0.01, 100) + asset = 1.5 * bench + RNG.normal(0, 0.001, 100) + b = beta(asset, bench) + assert abs(b - 1.5) < 0.05 + + def test_beta_rolling(self): + from ferro_ta.analysis.portfolio import beta + + bench = RNG.normal(0, 0.01, 100) + asset = bench + RNG.normal(0, 0.001, 100) + rb = beta(asset, bench, window=20) + assert rb.shape == (100,) + assert np.isnan(rb[0]) + assert not np.isnan(rb[-1]) + + def test_drawdown_series(self): + from ferro_ta.analysis.portfolio import drawdown + + eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0]) + dd, max_dd = drawdown(eq) + assert dd.shape == (5,) + assert dd[0] == 0.0 # no drawdown at start + assert max_dd < 0 + + def test_drawdown_max_only(self): + from ferro_ta.analysis.portfolio import drawdown + + eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0]) + max_dd = drawdown(eq, as_series=False) + assert isinstance(max_dd, float) + assert max_dd < 0 + + +# --------------------------------------------------------------------------- +# Cross-asset analytics +# --------------------------------------------------------------------------- + + +class TestCrossAsset: + def test_relative_strength_shape(self): + from ferro_ta.analysis.cross_asset import relative_strength + + ra = RNG.normal(0, 0.01, 50) + rb = RNG.normal(0, 0.01, 50) + rs = relative_strength(ra, rb) + assert rs.shape == (50,) + + def test_spread_values(self): + from ferro_ta.analysis.cross_asset import spread + + a = np.array([10.0, 11.0, 12.0]) + b = np.array([9.0, 10.0, 11.0]) + sp = spread(a, b) + np.testing.assert_allclose(sp, [1.0, 1.0, 1.0]) + + def test_spread_custom_hedge(self): + from ferro_ta.analysis.cross_asset import spread + + a = np.array([10.0, 10.0]) + b = np.array([5.0, 5.0]) + sp = spread(a, b, hedge=2.0) + np.testing.assert_allclose(sp, [0.0, 0.0]) + + def test_ratio_basic(self): + from ferro_ta.analysis.cross_asset import ratio + + a = np.array([10.0, 12.0, 15.0]) + b = np.array([5.0, 4.0, 5.0]) + r = ratio(a, b) + np.testing.assert_allclose(r, [2.0, 3.0, 3.0]) + + def test_ratio_zero_denominator(self): + from ferro_ta.analysis.cross_asset import ratio + + a = np.array([1.0, 2.0]) + b = np.array([0.0, 1.0]) + r = ratio(a, b) + assert np.isnan(r[0]) + assert r[1] == 2.0 + + def test_zscore_nan_warmup(self): + from ferro_ta.analysis.cross_asset import zscore + + x = np.array([1.0, 2.0, 3.0, 2.0, 1.0]) + z = zscore(x, window=3) + assert np.isnan(z[0]) and np.isnan(z[1]) + assert not np.isnan(z[2]) + + def test_rolling_beta_warmup(self): + from ferro_ta.analysis.cross_asset import rolling_beta + + b = RNG.normal(0, 1, 50) + a = 0.8 * b + RNG.normal(0, 0.1, 50) + rb = rolling_beta(a, b, window=20) + assert np.isnan(rb[18]) + assert not np.isnan(rb[19]) + + +# --------------------------------------------------------------------------- +# Feature matrix +# --------------------------------------------------------------------------- + + +class TestFeatureMatrix: + def test_basic_feature_matrix(self): + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(50) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10})]) + assert "SMA" in fm + arr = np.asarray(fm["SMA"] if isinstance(fm, dict) else fm["SMA"].values) + assert arr.shape == (50,) + + def test_multiple_indicators_feature_matrix(self): + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(50) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix( + ohlcv, + [ + ("SMA", {"timeperiod": 10}), + ("RSI", {"timeperiod": 14}), + ], + ) + assert "SMA" in fm + assert "RSI" in fm + + def test_nan_policy_drop(self): + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(50) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix( + ohlcv, + [("SMA", {"timeperiod": 10}), ("RSI", {"timeperiod": 14})], + nan_policy="drop", + ) + assert isinstance(fm, pd.DataFrame) + assert not fm.isnull().any().any() + + def test_feature_matrix_string_indicator(self): + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(50) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix(ohlcv, ["SMA"]) + assert "SMA" in fm + + def test_feature_matrix_mixed_fastpath_and_multi_output(self): + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(80) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix( + ohlcv, + [ + ("SMA", {"timeperiod": 10}), + ("ATR", {"timeperiod": 14}), + ("BBANDS", {"timeperiod": 10}, 1), + ], + ) + assert "SMA" in fm + assert "ATR" in fm + assert "BBANDS_1" in fm + + +class TestComputeMany: + def test_close_indicators_match_public_api(self): + from ferro_ta import EMA, RSI, SMA + from ferro_ta.data.batch import compute_many + + _, _, _, close, _ = _make_ohlcv(80) + results = compute_many( + [ + ("SMA", {"timeperiod": 10}), + ("EMA", {"timeperiod": 12}), + ("RSI", {"timeperiod": 14}), + ], + close=close, + ) + + np.testing.assert_allclose( + results[0], SMA(close, timeperiod=10), equal_nan=True + ) + np.testing.assert_allclose( + results[1], EMA(close, timeperiod=12), equal_nan=True + ) + np.testing.assert_allclose( + results[2], RSI(close, timeperiod=14), equal_nan=True + ) + + def test_hlc_indicators_match_public_api(self): + from ferro_ta import ADX, ATR + from ferro_ta.data.batch import compute_many + + _, high, low, close, _ = _make_ohlcv(80) + results = compute_many( + [ + ("ATR", {"timeperiod": 14}), + ("ADX", {"timeperiod": 14}), + ], + close=close, + high=high, + low=low, + ) + + np.testing.assert_allclose( + results[0], ATR(high, low, close, timeperiod=14), equal_nan=True + ) + np.testing.assert_allclose( + results[1], ADX(high, low, close, timeperiod=14), equal_nan=True + ) + + def test_unsupported_kwargs_fall_back_cleanly(self): + from ferro_ta import STDDEV + from ferro_ta.data.batch import compute_many + + _, _, _, close, _ = _make_ohlcv(80) + result = compute_many( + [("STDDEV", {"timeperiod": 10, "nbdev": 2.0})], close=close + ) + np.testing.assert_allclose( + result[0], STDDEV(close, timeperiod=10, nbdev=2.0), equal_nan=True + ) + + +# --------------------------------------------------------------------------- +# Viz (smoke tests) +# --------------------------------------------------------------------------- + + +class TestViz: + def test_plot_matplotlib_no_show(self): + pytest.importorskip("matplotlib") + from ferro_ta import RSI + from ferro_ta.tools.viz import plot + + o, h, l, c, v = _make_ohlcv(60) + ohlcv = {"close": c, "open": o, "high": h, "low": l, "volume": v} + rsi = RSI(c, timeperiod=14) + fig = plot( + ohlcv, + indicators={"RSI(14)": rsi}, + backend="matplotlib", + show=False, + ) + assert fig is not None + import matplotlib.pyplot as plt + + plt.close("all") + + def test_plot_unknown_backend_raises(self): + from ferro_ta.tools.viz import plot + + o, h, l, c, v = _make_ohlcv(10) + with pytest.raises(ValueError, match="Unknown backend"): + plot({"close": c}, backend="bogus") + + def test_plot_savefig(self, tmp_path): + pytest.importorskip("matplotlib") + from ferro_ta.tools.viz import plot + + o, h, l, c, v = _make_ohlcv(30) + ohlcv = {"close": c, "open": o, "high": h, "low": l, "volume": v} + out = str(tmp_path / "chart.png") + plot(ohlcv, backend="matplotlib", savefig=out, show=False) + import os + + assert os.path.exists(out) + import matplotlib.pyplot as plt + + plt.close("all") + + +# --------------------------------------------------------------------------- +# Data adapters +# --------------------------------------------------------------------------- + + +class TestDataAdapters: + def test_in_memory_adapter(self): + from ferro_ta.data.adapters import InMemoryAdapter + + o, h, l, c, v = _make_ohlcv(20) + adapter = InMemoryAdapter( + {"open": o, "high": h, "low": l, "close": c, "volume": v} + ) + ohlcv = adapter.fetch() + assert "close" in ohlcv + + def test_register_and_get_adapter(self): + from ferro_ta.data.adapters import DataAdapter, get_adapter, register_adapter + + class MyAdapter(DataAdapter): + def fetch(self, **kwargs): + return {} + + register_adapter("_test_my", MyAdapter) + cls = get_adapter("_test_my") + assert cls is MyAdapter + + def test_get_unknown_adapter_raises(self): + from ferro_ta.data.adapters import get_adapter + + with pytest.raises(KeyError): + get_adapter("_nonexistent_adapter_xyz") + + def test_csv_adapter_requires_pandas(self, tmp_path): + """CsvAdapter can be instantiated without pandas; fetch raises ImportError.""" + from ferro_ta.data.adapters import CsvAdapter + + adapter = CsvAdapter(str(tmp_path / "fake.csv")) + assert adapter is not None + + def test_csv_adapter_fetch(self, tmp_path): + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta.data.adapters import CsvAdapter + + o, h, l, c, v = _make_ohlcv(10) + csv_path = str(tmp_path / "ohlcv.csv") + df = pd.DataFrame({"open": o, "high": h, "low": l, "close": c, "volume": v}) + df.to_csv(csv_path, index=False) + adapter = CsvAdapter(csv_path) + result = adapter.fetch() + assert "close" in result.columns + assert len(result) == 10 + + def test_builtin_adapters_registered(self): + from ferro_ta.data.adapters import CsvAdapter, InMemoryAdapter, get_adapter + + assert get_adapter("csv") is CsvAdapter + assert get_adapter("memory") is InMemoryAdapter diff --git a/ferro-ta-main/tests/unit/test_dataframe_integration.py b/ferro-ta-main/tests/unit/test_dataframe_integration.py new file mode 100644 index 0000000..015f509 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/tests/unit/test_derivatives.py b/ferro-ta-main/tests/unit/test_derivatives.py new file mode 100644 index 0000000..d6dceef --- /dev/null +++ b/ferro-ta-main/tests/unit/test_derivatives.py @@ -0,0 +1,651 @@ +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + + +class TestOptionsAnalytics: + def test_black_scholes_price_scalar(self): + from ferro_ta.analysis.options import black_scholes_price + + price = black_scholes_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + ) + assert price == pytest.approx(10.4506, rel=1e-4) + + def test_black_76_price_vectorized(self): + from ferro_ta.analysis.options import black_76_price + + price = black_76_price( + np.array([100.0, 105.0]), + np.array([100.0, 100.0]), + 0.03, + 1.0, + np.array([0.2, 0.25]), + option_type="call", + ) + assert isinstance(price, np.ndarray) + assert price.shape == (2,) + assert np.all(price > 0.0) + + def test_greeks_and_iv_recovery(self): + from ferro_ta.analysis.options import greeks, implied_volatility, option_price + + price = option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + model="bsm", + ) + iv = implied_volatility( + price, + 100.0, + 100.0, + 0.05, + 1.0, + option_type="call", + model="bsm", + ) + result = greeks( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + model="bsm", + ) + assert iv == pytest.approx(0.2, rel=1e-6) + assert result.delta == pytest.approx(0.6368, rel=1e-3) + assert result.gamma > 0.0 + assert result.vega > 0.0 + + def test_smile_and_chain_helpers(self): + from ferro_ta.analysis.options import ( + label_moneyness, + select_strike, + smile_metrics, + term_structure_slope, + ) + + strikes = np.array([80.0, 90.0, 100.0, 110.0, 120.0]) + vols = np.array([0.30, 0.25, 0.20, 0.22, 0.27]) + + metrics = smile_metrics(strikes, vols, 100.0, 0.5) + labels = label_moneyness(strikes, 100.0, option_type="call") + + assert metrics.atm_iv == pytest.approx(0.20, rel=1e-6) + assert metrics.skew_slope < 0.0 + assert labels.tolist() == ["ITM", "ITM", "ATM", "OTM", "OTM"] + assert select_strike(strikes, 101.0, selector="ATM") == 100.0 + assert ( + select_strike(strikes, 101.0, option_type="call", selector="OTM2") == 120.0 + ) + assert select_strike( + strikes, + 100.0, + selector="DELTA0.25", + option_type="call", + volatilities=vols, + time_to_expiry=0.5, + ) in set(strikes.tolist()) + assert term_structure_slope([0.1, 0.5, 1.0], [0.18, 0.20, 0.22]) > 0.0 + + +class TestFuturesAnalytics: + def test_basis_and_curve_helpers(self): + from ferro_ta.analysis.futures import ( + annualized_basis, + basis, + calendar_spreads, + carry_spread, + curve_summary, + implied_carry_rate, + synthetic_forward, + ) + + assert basis(100.0, 103.0) == pytest.approx(3.0) + assert annualized_basis(100.0, 103.0, 0.25) > 0.0 + assert implied_carry_rate(100.0, 103.0, 0.25) > 0.0 + assert carry_spread(100.0, 103.0, 0.02, 0.25) > -1.0 + assert synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5) > 100.0 + assert np.allclose(calendar_spreads([100.0, 101.0, 103.0]), [1.0, 2.0]) + + summary = curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]) + assert summary.is_contango is True + assert summary.slope > 0.0 + + def test_roll_helpers(self): + from ferro_ta.analysis.futures import ( + back_adjusted_continuous_contract, + ratio_adjusted_continuous_contract, + roll_yield, + weighted_continuous_contract, + ) + + front = np.array([100.0, 101.0, 102.0, 103.0]) + nxt = np.array([101.0, 102.0, 103.0, 104.0]) + weights = np.array([0.0, 0.25, 0.75, 1.0]) + + weighted = weighted_continuous_contract(front, nxt, weights) + back_adjusted = back_adjusted_continuous_contract(front, nxt, weights) + ratio_adjusted = ratio_adjusted_continuous_contract(front, nxt, weights) + + assert weighted.shape == front.shape + assert back_adjusted.shape == front.shape + assert ratio_adjusted.shape == front.shape + assert roll_yield(100.0, 102.0, 30.0 / 365.0) > 0.0 + + +class TestStrategyAndPayoff: + def test_strategy_schema_and_preset(self): + from ferro_ta.analysis.options_strategy import ( + DerivativesStrategy, + ExpirySelector, + ExpirySelectorKind, + LegPreset, + StrategyLeg, + StrikeSelector, + StrikeSelectorKind, + build_strategy_preset, + ) + + preset = build_strategy_preset( + LegPreset.STRADDLE, + name="ATM Straddle", + underlying="NIFTY", + expiry_selector=ExpirySelector(ExpirySelectorKind.CURRENT_WEEK), + ) + custom = DerivativesStrategy( + name="Custom Single", + legs=( + StrategyLeg( + "NIFTY", + ExpirySelector(ExpirySelectorKind.CURRENT_WEEK), + StrikeSelector( + StrikeSelectorKind.EXPLICIT, explicit_strike=22000.0 + ), + "call", + ), + ), + ) + + assert len(preset.legs) == 2 + assert custom.to_dict()["name"] == "Custom Single" + + def test_payoff_and_aggregate_greeks(self): + from ferro_ta.analysis.derivatives_payoff import ( + PayoffLeg, + aggregate_greeks, + strategy_payoff, + ) + + spot_grid = np.array([90.0, 100.0, 110.0]) + legs = [ + PayoffLeg( + instrument="option", + side="long", + option_type="call", + strike=100.0, + premium=5.0, + volatility=0.2, + time_to_expiry=0.5, + ), + PayoffLeg( + instrument="option", + side="short", + option_type="call", + strike=110.0, + premium=2.0, + volatility=0.22, + time_to_expiry=0.5, + ), + PayoffLeg(instrument="future", side="long", entry_price=100.0), + ] + + payoff = strategy_payoff(spot_grid, legs=legs) + greeks = aggregate_greeks(100.0, legs=legs) + + assert payoff.shape == spot_grid.shape + assert payoff[1] == pytest.approx(-3.0) + assert greeks.delta > 0.0 + assert greeks.gamma > 0.0 + + +class TestStockInstrument: + def test_stock_leg_payoff_linear(self): + from ferro_ta.analysis.derivatives_payoff import stock_leg_payoff + + spot_grid = np.array([90.0, 100.0, 110.0]) + payoff = stock_leg_payoff(spot_grid, entry_price=100.0, side="long") + assert payoff == pytest.approx([-10.0, 0.0, 10.0]) + + def test_stock_leg_short_side(self): + from ferro_ta.analysis.derivatives_payoff import stock_leg_payoff + + spot_grid = np.array([90.0, 100.0, 110.0]) + payoff = stock_leg_payoff(spot_grid, entry_price=100.0, side="short") + assert payoff == pytest.approx([10.0, 0.0, -10.0]) + + def test_strategy_payoff_with_stock_leg(self): + from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff + + # Covered call: long stock + short call + spot_grid = np.array([90.0, 100.0, 110.0, 120.0]) + legs = [ + PayoffLeg(instrument="stock", side="long", entry_price=100.0), + PayoffLeg( + instrument="option", + side="short", + option_type="call", + strike=110.0, + premium=3.0, + ), + ] + payoff = strategy_payoff(spot_grid, legs=legs) + assert payoff.shape == spot_grid.shape + # At 90: stock P&L = -10, short call = +3 (OTM) → total = -7 + assert payoff[0] == pytest.approx(-7.0) + # At 110: stock P&L = +10, short call = +3 (ATM, intrinsic=0) → total = +13 + assert payoff[2] == pytest.approx(13.0) + + def test_strategy_leg_accepts_stock_instrument(self): + from ferro_ta.analysis.options_strategy import StrategyLeg + + leg = StrategyLeg( + underlying="NIFTY", + expiry_selector=None, + strike_selector=None, + option_type=None, + instrument="stock", + side="long", + ) + assert leg.instrument == "stock" + + +class TestExtendedGreeks: + def test_extended_greeks_returns_five_values(self): + from ferro_ta.analysis.options import ExtendedGreeks, extended_greeks + + eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.2, option_type="call") + assert isinstance(eg, ExtendedGreeks) + assert eg.vanna is not None + assert eg.volga is not None + assert eg.charm is not None + assert eg.speed is not None + assert eg.color is not None + + def test_vanna_sign_otm_call(self): + # OTM call vanna > 0 (delta increases as vol rises) + from ferro_ta.analysis.options import extended_greeks + + eg = extended_greeks(100.0, 110.0, 0.05, 1.0, 0.2, option_type="call") + assert eg.vanna > 0.0 + + def test_extended_greeks_finite_for_valid_inputs(self): + from ferro_ta.analysis.options import extended_greeks + + eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.25, option_type="put") + assert np.isfinite(eg.vanna) + assert np.isfinite(eg.volga) + assert np.isfinite(eg.charm) + assert np.isfinite(eg.speed) + assert np.isfinite(eg.color) + + def test_volga_positive_atm(self): + # Volga is always non-negative for standard BSM inputs + from ferro_ta.analysis.options import extended_greeks + + eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.2, option_type="call") + assert eg.volga >= 0.0 + + +class TestDigitalOptions: + def test_cash_or_nothing_call_atm(self): + from ferro_ta.analysis.options import digital_option_price + + # ATM cash-or-nothing call ≈ e^{-rT} * N(d2) ≈ 0.532 + price = digital_option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + digital_type="cash_or_nothing", + ) + assert 0.0 < price < 1.0 + assert price == pytest.approx(0.532, rel=0.02) + + def test_asset_or_nothing_call_atm(self): + from ferro_ta.analysis.options import digital_option_price + + price = digital_option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + digital_type="asset_or_nothing", + ) + # asset-or-nothing call ≈ S * N(d1) < S + assert 0.0 < price < 100.0 + + def test_put_call_parity_cash_or_nothing(self): + from ferro_ta.analysis.options import digital_option_price + + call = digital_option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.25, + option_type="call", + digital_type="cash_or_nothing", + ) + put = digital_option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.25, + option_type="put", + digital_type="cash_or_nothing", + ) + discount = np.exp(-0.05) + assert call + put == pytest.approx(discount, rel=1e-6) + + def test_digital_greeks_finite(self): + from ferro_ta.analysis.options import digital_option_greeks + + g = digital_option_greeks( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + digital_type="cash_or_nothing", + ) + assert np.isfinite(g.delta) + assert np.isfinite(g.gamma) + assert np.isfinite(g.vega) + + def test_digital_invalid_returns_nan(self): + from ferro_ta.analysis.options import digital_option_price + + price = digital_option_price( + -1.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + digital_type="cash_or_nothing", + ) + assert np.isnan(price) + + +class TestAmericanOptions: + def test_american_price_gte_european(self): + from ferro_ta.analysis.options import american_option_price, option_price + + spot, strike, rate, tte, vol = 100.0, 100.0, 0.05, 1.0, 0.2 + american = american_option_price( + spot, strike, rate, tte, vol, option_type="call" + ) + european = option_price(spot, strike, rate, tte, vol, option_type="call") + assert american >= european - 1e-8 + + def test_early_exercise_premium_nonnegative(self): + from ferro_ta.analysis.options import early_exercise_premium + + premium = early_exercise_premium( + 100.0, 100.0, 0.05, 1.0, 0.2, option_type="put" + ) + assert premium >= 0.0 + + def test_american_put_early_exercise_positive(self): + # Deep ITM put with high rate should have meaningful early exercise premium + from ferro_ta.analysis.options import early_exercise_premium + + premium = early_exercise_premium(80.0, 100.0, 0.1, 0.5, 0.25, option_type="put") + assert premium > 0.0 + + def test_american_call_no_dividends_no_premium(self): + # With zero carry (no dividends), American call = European call + from ferro_ta.analysis.options import early_exercise_premium + + premium = early_exercise_premium( + 100.0, 100.0, 0.05, 1.0, 0.2, option_type="call", carry=0.0 + ) + assert premium == pytest.approx(0.0, abs=1e-4) + + +class TestVolEstimators: + @pytest.fixture + def sample_ohlc(self): + rng = np.random.default_rng(42) + n = 100 + log_ret = rng.normal(0.0, 0.01, n) + close = 100.0 * np.cumprod(np.exp(log_ret)) + high = close * np.exp(np.abs(rng.normal(0.0, 0.005, n))) + low = close * np.exp(-np.abs(rng.normal(0.0, 0.005, n))) + open_ = np.roll(close, 1) + open_[0] = close[0] + return open_, high, low, close + + def test_close_to_close_vol_length(self, sample_ohlc): + from ferro_ta.analysis.options import close_to_close_vol + + _, _, _, close = sample_ohlc + out = close_to_close_vol(close, window=20) + assert len(out) == len(close) + + def test_close_to_close_vol_warmup_nan(self, sample_ohlc): + from ferro_ta.analysis.options import close_to_close_vol + + _, _, _, close = sample_ohlc + out = close_to_close_vol(close, window=20) + # First `window` values are NaN; index `window` is the first valid value + assert all(np.isnan(out[:20])) + assert np.isfinite(out[20]) + + def test_parkinson_vol_finite_and_positive(self, sample_ohlc): + from ferro_ta.analysis.options import parkinson_vol + + _, high, low, _ = sample_ohlc + out = parkinson_vol(high, low, window=20) + finite = out[~np.isnan(out)] + assert len(finite) > 0 + assert np.all(finite > 0.0) + + def test_garman_klass_vol(self, sample_ohlc): + from ferro_ta.analysis.options import garman_klass_vol + + open_, high, low, close = sample_ohlc + out = garman_klass_vol(open_, high, low, close, window=20) + finite = out[~np.isnan(out)] + assert len(finite) > 0 + assert np.all(finite > 0.0) + + def test_rogers_satchell_vol(self, sample_ohlc): + from ferro_ta.analysis.options import rogers_satchell_vol + + open_, high, low, close = sample_ohlc + out = rogers_satchell_vol(open_, high, low, close, window=20) + finite = out[~np.isnan(out)] + assert len(finite) > 0 + + def test_yang_zhang_vol(self, sample_ohlc): + from ferro_ta.analysis.options import yang_zhang_vol + + open_, high, low, close = sample_ohlc + out = yang_zhang_vol(open_, high, low, close, window=20) + finite = out[~np.isnan(out)] + assert len(finite) > 0 + assert np.all(finite > 0.0) + + def test_yang_zhang_lower_variance_than_close_to_close(self, sample_ohlc): + # YZ is more efficient than close-to-close + from ferro_ta.analysis.options import close_to_close_vol, yang_zhang_vol + + open_, high, low, close = sample_ohlc + c2c = close_to_close_vol(close, window=20) + yz = yang_zhang_vol(open_, high, low, close, window=20) + valid = ~np.isnan(c2c) & ~np.isnan(yz) + # YZ variance < C2C variance (efficiency test) + assert np.var(yz[valid]) <= np.var(c2c[valid]) * 2.0 # lenient bound + + +class TestVolCone: + def test_vol_cone_shape(self): + from ferro_ta.analysis.options import VolCone, vol_cone + + rng = np.random.default_rng(0) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 300))) + cone = vol_cone(close, windows=(21, 42, 63)) + assert isinstance(cone, VolCone) + assert len(cone.windows) == 3 + assert len(cone.min) == 3 + + def test_vol_cone_monotonic_percentiles(self): + from ferro_ta.analysis.options import vol_cone + + rng = np.random.default_rng(1) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500))) + cone = vol_cone(close, windows=(21, 42, 63, 126, 252)) + for i in range(len(cone.windows)): + assert ( + cone.min[i] + <= cone.p25[i] + <= cone.median[i] + <= cone.p75[i] + <= cone.max[i] + ) + + def test_vol_cone_positive_values(self): + from ferro_ta.analysis.options import vol_cone + + rng = np.random.default_rng(2) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 400))) + cone = vol_cone(close) + assert np.all(cone.min > 0.0) + + +class TestStrategyAnalytics: + def test_put_call_parity_deviation_zero(self): + from ferro_ta.analysis.options import option_price, put_call_parity_deviation + + s, k, r, tte, vol = 100.0, 100.0, 0.05, 1.0, 0.2 + call = option_price(s, k, r, tte, vol, option_type="call") + put = option_price(s, k, r, tte, vol, option_type="put") + dev = put_call_parity_deviation(call, put, s, k, r, tte) + assert dev == pytest.approx(0.0, abs=1e-6) + + def test_put_call_parity_deviation_nonzero_for_stale_quote(self): + from ferro_ta.analysis.options import put_call_parity_deviation + + dev = put_call_parity_deviation(15.0, 5.0, 100.0, 100.0, 0.05, 1.0) + assert abs(dev) > 0.01 + + def test_expected_move_positive(self): + from ferro_ta.analysis.options import expected_move + + lower, upper = expected_move(100.0, 0.2, 30.0) + assert upper > 0.0 + assert lower < 0.0 + + def test_expected_move_log_normal_asymmetry(self): + # Log-normal expected move: upper > |lower| (right-skew) + from ferro_ta.analysis.options import expected_move + + lower, upper = expected_move(100.0, 0.2, 30.0) + # Both magnitudes are similar (within 10%) but upper > |lower| + assert upper > abs(lower) * 0.95 + assert upper < abs(lower) * 2.0 + + def test_strategy_value_near_expiry_approx_payoff(self): + from ferro_ta.analysis.derivatives_payoff import ( + PayoffLeg, + strategy_payoff, + strategy_value, + ) + + # Near expiry, BSM value ≈ intrinsic payoff + spot_grid = np.array([90.0, 100.0, 110.0]) + legs = [ + PayoffLeg( + instrument="option", + side="long", + option_type="call", + strike=100.0, + premium=0.0, + volatility=0.2, + time_to_expiry=0.001, + ) + ] + val = strategy_value(spot_grid, legs=legs, time_to_expiry=0.001, volatility=0.2) + payoff = strategy_payoff(spot_grid, legs=legs) + # Near expiry, value ≈ payoff (within a few cents) + assert np.allclose(val, payoff, atol=0.5) + + def test_strategy_value_shape(self): + from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_value + + spot_grid = np.linspace(80.0, 120.0, 20) + legs = [ + PayoffLeg( + instrument="option", + side="long", + option_type="call", + strike=100.0, + premium=5.0, + volatility=0.2, + time_to_expiry=0.5, + ) + ] + val = strategy_value(spot_grid, legs=legs, time_to_expiry=0.5, volatility=0.2) + assert val.shape == spot_grid.shape + + +class TestDerivativesBenchmarking: + def test_derivatives_benchmark_smoke(self, tmp_path): + root = Path(__file__).resolve().parents[2] + script = root / "benchmarks" / "bench_derivatives_compare.py" + output_path = tmp_path / "derivatives_benchmark.json" + + completed = subprocess.run( + [ + sys.executable, + str(script), + "--sizes", + "32", + "--accuracy-size", + "16", + "--json", + str(output_path), + ], + cwd=root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + assert output_path.is_file() + payload = output_path.read_text(encoding="utf-8") + assert '"accuracy"' in payload + assert '"speed"' in payload + assert '"provider": "ferro_ta"' in payload diff --git a/ferro-ta-main/tests/unit/test_derivatives_accuracy.py b/ferro-ta-main/tests/unit/test_derivatives_accuracy.py new file mode 100644 index 0000000..89d4765 --- /dev/null +++ b/ferro-ta-main/tests/unit/test_derivatives_accuracy.py @@ -0,0 +1,608 @@ +""" +Accuracy/correctness tests for ferro-ta derivatives analytics. + +Each test class validates the ferro-ta implementation against reference +formulas implemented using scipy and numpy. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Reference formulas (pure numpy / scipy) +# --------------------------------------------------------------------------- + + +def _norm_cdf(x): + """Standard normal CDF via scipy.""" + from scipy.stats import norm as _norm + + return _norm.cdf(x) + + +def _norm_pdf(x): + from scipy.stats import norm as _norm + + return _norm.pdf(x) + + +def bsm_call(S, K, r, q, T, sigma): # noqa: N803 + """Reference BSM call price.""" + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + return S * np.exp(-q * T) * _norm_cdf(d1) - K * np.exp(-r * T) * _norm_cdf(d2) + + +def bsm_put(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + return K * np.exp(-r * T) * _norm_cdf(-d2) - S * np.exp(-q * T) * _norm_cdf(-d1) + + +def bsm_delta_call(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return np.exp(-q * T) * _norm_cdf(d1) + + +def digital_cash_call(S, K, r, q, T, sigma): # noqa: N803 + d2 = (np.log(S / K) + (r - q - 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return np.exp(-r * T) * _norm_cdf(d2) + + +def digital_asset_call(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return S * np.exp(-q * T) * _norm_cdf(d1) + + +def digital_cash_put(S, K, r, q, T, sigma): # noqa: N803 + d2 = (np.log(S / K) + (r - q - 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return np.exp(-r * T) * _norm_cdf(-d2) + + +def digital_asset_put(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return S * np.exp(-q * T) * _norm_cdf(-d1) + + +def vanna_num(S, K, r, q, T, sigma, eps=1e-4): # noqa: N803 + """∂Δ/∂σ via central differences.""" + delta_up = bsm_delta_call(S, K, r, q, T, sigma + eps) + delta_dn = bsm_delta_call(S, K, r, q, T, sigma - eps) + return (delta_up - delta_dn) / (2 * eps) + + +def vega_bsm(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return S * np.exp(-q * T) * _norm_pdf(d1) * np.sqrt(T) + + +def volga_num(S, K, r, q, T, sigma, eps=1e-4): # noqa: N803 + """∂²V/∂σ² via central differences.""" + v_up = vega_bsm(S, K, r, q, T, sigma + eps) + v_dn = vega_bsm(S, K, r, q, T, sigma - eps) + return (v_up - v_dn) / (2 * eps) + + +def ctc_vol_reference(close, window, trading_days=252.0): + """Close-to-close vol: rolling std of log returns × sqrt(trading_days).""" + log_ret = np.log(close[1:] / close[:-1]) + n = len(close) + out = np.full(n, np.nan) + for i in range(window, n): + returns_window = log_ret[i - window : i] + out[i] = np.sqrt(np.sum(returns_window**2) / window * trading_days) + return out + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + +# Six parameter sets: ATM, 10% OTM, 10% ITM, low vol, high vol, non-zero carry +_DIGITAL_CASES = [ + # (S, K, r, q, T, sigma, label) + (100.0, 100.0, 0.05, 0.00, 1.0, 0.20, "ATM"), + (100.0, 110.0, 0.05, 0.00, 1.0, 0.20, "10% OTM"), + (100.0, 90.0, 0.05, 0.00, 1.0, 0.20, "10% ITM"), + (100.0, 100.0, 0.05, 0.00, 1.0, 0.05, "low vol"), + (100.0, 100.0, 0.05, 0.00, 1.0, 0.50, "high vol"), + (100.0, 100.0, 0.05, 0.03, 1.0, 0.20, "non-zero carry"), +] + + +class TestDigitalOptionsAccuracy: + @pytest.fixture(autouse=True) + def require_scipy(self): + pytest.importorskip("scipy") + + def test_cash_or_nothing_call_vs_reference(self): + from ferro_ta.analysis.options import digital_option_price + + for S, K, r, q, T, sigma, label in _DIGITAL_CASES: + expected = digital_cash_call(S, K, r, q, T, sigma) + actual = digital_option_price( + S, + K, + r, + T, + sigma, + option_type="call", + digital_type="cash_or_nothing", + carry=q, + ) + assert actual == pytest.approx(expected, abs=1e-6), ( + f"cash_or_nothing call mismatch for case '{label}': " + f"got {actual}, expected {expected}" + ) + + def test_cash_or_nothing_put_vs_reference(self): + from ferro_ta.analysis.options import digital_option_price + + for S, K, r, q, T, sigma, label in _DIGITAL_CASES: + expected = digital_cash_put(S, K, r, q, T, sigma) + actual = digital_option_price( + S, + K, + r, + T, + sigma, + option_type="put", + digital_type="cash_or_nothing", + carry=q, + ) + assert actual == pytest.approx(expected, abs=1e-6), ( + f"cash_or_nothing put mismatch for case '{label}': " + f"got {actual}, expected {expected}" + ) + + def test_asset_or_nothing_call_vs_reference(self): + from ferro_ta.analysis.options import digital_option_price + + for S, K, r, q, T, sigma, label in _DIGITAL_CASES: + expected = digital_asset_call(S, K, r, q, T, sigma) + actual = digital_option_price( + S, + K, + r, + T, + sigma, + option_type="call", + digital_type="asset_or_nothing", + carry=q, + ) + # Tolerance 1e-4: asset-or-nothing involves S * N(d1), small numerical diff expected + assert actual == pytest.approx(expected, abs=1e-4), ( + f"asset_or_nothing call mismatch for case '{label}': " + f"got {actual}, expected {expected}" + ) + + def test_asset_or_nothing_put_vs_reference(self): + from ferro_ta.analysis.options import digital_option_price + + for S, K, r, q, T, sigma, label in _DIGITAL_CASES: + expected = digital_asset_put(S, K, r, q, T, sigma) + actual = digital_option_price( + S, + K, + r, + T, + sigma, + option_type="put", + digital_type="asset_or_nothing", + carry=q, + ) + # Tolerance 1e-4: asset-or-nothing involves S * N(-d1), small numerical diff expected + assert actual == pytest.approx(expected, abs=1e-4), ( + f"asset_or_nothing put mismatch for case '{label}': " + f"got {actual}, expected {expected}" + ) + + def test_batch_digital_price_matches_scalar(self): + """Vectorized call must match scalar loop for 10 random points.""" + from ferro_ta.analysis.options import digital_option_price + + rng = np.random.default_rng(7) + n = 10 + S_arr = rng.uniform(80.0, 120.0, n) + K_arr = rng.uniform(80.0, 120.0, n) + r_arr = rng.uniform(0.01, 0.10, n) + T_arr = rng.uniform(0.1, 2.0, n) + sigma_arr = rng.uniform(0.10, 0.50, n) + + batch = digital_option_price( + S_arr, + K_arr, + r_arr, + T_arr, + sigma_arr, + option_type="call", + digital_type="cash_or_nothing", + ) + + scalar_results = np.array( + [ + digital_option_price( + float(S_arr[i]), + float(K_arr[i]), + float(r_arr[i]), + float(T_arr[i]), + float(sigma_arr[i]), + option_type="call", + digital_type="cash_or_nothing", + ) + for i in range(n) + ] + ) + + assert batch == pytest.approx(scalar_results, abs=1e-10), ( + "Batch digital_option_price does not match scalar loop" + ) + + +# Four cases for extended Greeks: ITM call, ATM call, OTM call, ATM put +_GREEK_CASES = [ + # (S, K, r, q, T, sigma, option_type, label) + (110.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "ITM call"), + (100.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "ATM call"), + (90.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "OTM call"), + (100.0, 100.0, 0.05, 0.0, 1.0, 0.20, "put", "ATM put"), +] + + +class TestExtendedGreeksAccuracy: + @pytest.fixture(autouse=True) + def require_scipy(self): + pytest.importorskip("scipy") + + def test_vanna_vs_numerical_fd(self): + """extended_greeks().vanna matches ∂Δ/∂σ from central differences (tol=1e-3).""" + from ferro_ta.analysis.options import extended_greeks + + for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES: + eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q) + # Reference is defined only for calls; for put use numerical FD directly + if opt_type == "call": + expected = vanna_num(S, K, r, q, T, sigma) + else: + # Vanna for put: ∂(put delta)/∂σ = ∂(call delta - e^{-qT})/∂σ = vanna_call + expected = vanna_num(S, K, r, q, T, sigma) + assert float(eg.vanna) == pytest.approx(expected, abs=1e-3), ( + f"Vanna mismatch for '{label}': got {eg.vanna}, expected {expected}" + ) + + def test_volga_vs_numerical_fd(self): + """extended_greeks().volga matches ∂²V/∂σ² from central differences (tol=1e-2).""" + from ferro_ta.analysis.options import extended_greeks + + for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES: + eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q) + expected = volga_num(S, K, r, q, T, sigma) + assert float(eg.volga) == pytest.approx(expected, abs=1e-2), ( + f"Volga mismatch for '{label}': got {eg.volga}, expected {expected}" + ) + + def test_speed_negative_for_calls(self): + """Speed (∂Γ/∂S) should be negative for OTM calls — Gamma decreases as S moves away.""" + from ferro_ta.analysis.options import extended_greeks + + # OTM call: S < K + eg = extended_greeks(90.0, 100.0, 0.05, 1.0, 0.20, option_type="call") + assert float(eg.speed) < 0.0, ( + f"Speed should be negative for OTM call, got {eg.speed}" + ) + + def test_charm_finite_for_valid_inputs(self): + """Charm should be finite and non-zero for non-degenerate inputs.""" + from ferro_ta.analysis.options import extended_greeks + + for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES: + eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q) + assert np.isfinite(float(eg.charm)), ( + f"Charm is not finite for '{label}': {eg.charm}" + ) + assert eg.charm != 0.0, ( + f"Charm is zero for '{label}' — unexpected for non-degenerate inputs" + ) + + +class TestAmericanOptionsAccuracy: + """Property-based tests for American options (no scipy required).""" + + def test_baw_vs_published_values(self): + """BAW American put satisfies the lower bound: price ≥ max(K - S, European BSM put). + + The Haug (2007) table uses b = r - q (cost of carry convention). Rather + than replicate the exact table — which requires matching the BAW carry + convention precisely — we verify two model-agnostic inequalities that any + correct American-put implementation must satisfy: + + 1. American put ≥ intrinsic value (K - S) + 2. American put ≥ European BSM put (early exercise has non-negative value) + """ + from ferro_ta.analysis.options import american_option_price, option_price + + S, K, r, T, sigma = 100.0, 100.0, 0.10, 0.25, 0.20 + american = american_option_price(S, K, r, T, sigma, option_type="put") + european = option_price(S, K, r, T, sigma, option_type="put") + + assert american >= max(K - S, 0.0) - 1e-8, ( + f"American put below intrinsic: {american:.4f} < {max(K - S, 0.0)}" + ) + assert american >= european - 1e-8, ( + f"American put below European put: {american:.4f} < {european:.4f}" + ) + # Sanity-check: American ATM put should be in a reasonable range + assert 0.0 < american < K, ( + f"American put price {american:.4f} is outside (0, K={K})" + ) + + def test_american_put_increases_with_strike(self): + """Deeper ITM (higher strike for put) ⇒ higher American put price. + + Uses moderately spaced strikes to avoid the intrinsic-value floor + where K - S becomes the binding constraint and the increments are + exactly 1-for-1, which can mask ordering issues near the floor. + """ + from ferro_ta.analysis.options import american_option_price + + # S = 100, K in {85, 100, 115}; rate and carry both 0.05 to avoid b=0 issues + S, r, T, sigma = 100.0, 0.05, 0.5, 0.25 + strikes = [85.0, 100.0, 115.0] + prices = [ + american_option_price(S, K, r, T, sigma, option_type="put", carry=r) + for K in strikes + ] + assert prices[0] < prices[1] < prices[2], ( + f"American put prices not monotone in strike: " + f"K={strikes} → prices={[round(p, 4) for p in prices]}" + ) + + def test_american_call_increases_with_spot(self): + """Higher spot ⇒ higher American call price.""" + from ferro_ta.analysis.options import american_option_price + + spots = [90.0, 100.0, 110.0] + prices = [ + american_option_price(S, 100.0, 0.05, 1.0, 0.20, option_type="call") + for S in spots + ] + assert prices[0] < prices[1] < prices[2], ( + f"American call prices not monotone in spot: {prices}" + ) + + def test_american_call_equals_european_no_dividends_no_early_exercise(self): + """American call with no early-exercise incentive (carry=0) ≈ European call. + + When the cost-of-carry parameter is zero, there is no dividend/carry + benefit to holding the underlying. In this regime, it is never + optimal to early-exercise an American call, so the American call price + equals the European call price computed with the same carry=0 convention. + The `early_exercise_premium` function exposes this directly and should + return ~0 for calls with carry=0. + """ + from ferro_ta.analysis.options import early_exercise_premium + + S, K, r, T, sigma = 100.0, 100.0, 0.05, 1.0, 0.20 + premium = early_exercise_premium( + S, K, r, T, sigma, option_type="call", carry=0.0 + ) + assert premium == pytest.approx(0.0, abs=1e-4), ( + f"Early exercise premium for call with carry=0 should be ~0, got {premium:.6f}" + ) + + def test_early_exercise_premium_positive_for_deep_itm_put(self): + """Deep ITM American put should have a meaningful early exercise premium. + + When S is well below K (deep ITM put), the time value is low and the + interest gained from early exercise of the put dominates — leading to a + positive early-exercise premium. + """ + from ferro_ta.analysis.options import early_exercise_premium + + # Deep ITM: S=70, K=100 — strong incentive to exercise early + premium = early_exercise_premium( + 70.0, 100.0, 0.10, 1.0, 0.20, option_type="put" + ) + assert premium > 0.0, ( + f"Deep ITM American put early exercise premium should be > 0, got {premium}" + ) + + +class TestVolEstimatorsAccuracy: + @pytest.fixture(autouse=True) + def require_scipy(self): + pytest.importorskip("scipy") + + def test_close_to_close_vs_reference_impl(self): + """C2C vol matches reference formula exactly (tol=1e-10), 100 samples.""" + from ferro_ta.analysis.options import close_to_close_vol + + rng = np.random.default_rng(42) + log_ret = rng.normal(0.0, 0.01, 100) + close = 100.0 * np.cumprod(np.exp(log_ret)) + + window = 20 + actual = close_to_close_vol(close, window=window, trading_days_per_year=252.0) + expected = ctc_vol_reference(close, window=window, trading_days=252.0) + + valid = ~np.isnan(expected) + assert np.allclose(actual[valid], expected[valid], atol=1e-10), ( + "close_to_close_vol does not match reference formula" + ) + + def test_constant_returns_known_vol(self): + """Constant daily log-return of 0.01 → C2C vol = 0.01 * sqrt(252) ≈ 0.1587.""" + from ferro_ta.analysis.options import close_to_close_vol + + # Build a price series with constant daily log-return of 0.01 + n = 100 + constant_log_ret = 0.01 + close = 100.0 * np.exp(np.arange(n) * constant_log_ret) + + window = 21 + out = close_to_close_vol(close, window=window, trading_days_per_year=252.0) + + # Expected: sqrt(0.01^2 * 252) = 0.01 * sqrt(252) + expected_vol = constant_log_ret * np.sqrt(252.0) + valid = ~np.isnan(out) + assert np.all(valid[window:]), "Expected valid values after warmup" + assert out[window] == pytest.approx(expected_vol, rel=1e-10), ( + f"Constant-return vol: got {out[window]}, expected {expected_vol}" + ) + + def test_parkinson_lognormal_unbiased(self): + """Parkinson estimator within 50% of true vol=0.20 for simulated OHLC data. + + Parkinson uses the log(high/low) range as a proxy for daily realized + vol. The estimator is unbiased for a Brownian-motion diffusion where + the daily range follows a known distribution, but a simplified + simulation (single end-of-day price + independent range draw) will + underestimate the range. We therefore build a proper multi-step + intraday path so the high/low reflects the true diffusion range, + and use a lenient 50% tolerance to accommodate finite-sample noise. + """ + from ferro_ta.analysis.options import parkinson_vol + + rng = np.random.default_rng(123) + true_vol = 0.20 + n_days = 500 + steps_per_day = 50 # intraday steps to get a realistic H-L range + daily_sigma = true_vol / np.sqrt(252.0) + step_sigma = daily_sigma / np.sqrt(steps_per_day) + + # Simulate intraday paths, extract open/high/low/close each day + highs = np.empty(n_days) + lows = np.empty(n_days) + price = 100.0 + for i in range(n_days): + intraday = price * np.exp( + np.cumsum(rng.normal(0.0, step_sigma, steps_per_day)) + ) + path = np.concatenate([[price], intraday]) + highs[i] = path.max() + lows[i] = path.min() + price = intraday[-1] + + window = 21 + out = parkinson_vol(highs, lows, window=window, trading_days_per_year=252.0) + valid = out[~np.isnan(out)] + + assert len(valid) > 0, "No valid Parkinson estimates" + median_est = float(np.median(valid)) + assert abs(median_est - true_vol) < 0.50 * true_vol, ( + f"Parkinson estimate {median_est:.4f} is more than 50% from true vol {true_vol}" + ) + + def test_vol_estimators_all_positive_finite(self): + """All 5 estimators produce finite and positive non-NaN values on random OHLC.""" + from ferro_ta.analysis.options import ( + close_to_close_vol, + garman_klass_vol, + parkinson_vol, + rogers_satchell_vol, + yang_zhang_vol, + ) + + rng = np.random.default_rng(99) + n = 200 + log_ret = rng.normal(0.0, 0.01, n) + close = 100.0 * np.cumprod(np.exp(log_ret)) + high = close * np.exp(np.abs(rng.normal(0.0, 0.005, n))) + low = close * np.exp(-np.abs(rng.normal(0.0, 0.005, n))) + open_ = np.roll(close, 1) + open_[0] = close[0] + + window = 20 + estimators = { + "close_to_close": close_to_close_vol(close, window=window), + "parkinson": parkinson_vol(high, low, window=window), + "garman_klass": garman_klass_vol(open_, high, low, close, window=window), + "rogers_satchell": rogers_satchell_vol( + open_, high, low, close, window=window + ), + "yang_zhang": yang_zhang_vol(open_, high, low, close, window=window), + } + + for name, out in estimators.items(): + valid = out[~np.isnan(out)] + assert len(valid) > 0, f"{name}: no valid (non-NaN) estimates" + assert np.all(np.isfinite(valid)), f"{name}: non-finite values present" + assert np.all(valid > 0.0), f"{name}: non-positive values present" + + +class TestVolConeAccuracy: + """Tests for vol_cone — no scipy required.""" + + def test_cone_windows_match_requested(self): + """Output windows should match the input list exactly.""" + from ferro_ta.analysis.options import vol_cone + + rng = np.random.default_rng(0) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500))) + requested = (10, 21, 42) + cone = vol_cone(close, windows=requested) + + assert list(cone.windows.astype(int)) == list(requested), ( + f"Cone windows {list(cone.windows)} do not match requested {list(requested)}" + ) + + def test_cone_median_matches_rolling_median(self): + """Manually computed rolling C2C vol median for window=21 should match cone.median[0].""" + from ferro_ta.analysis.options import close_to_close_vol, vol_cone + + rng = np.random.default_rng(5) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500))) + window = 21 + + cone = vol_cone(close, windows=(window,)) + + rolling = close_to_close_vol(close, window=window, trading_days_per_year=252.0) + valid = rolling[~np.isnan(rolling)] + manual_median = float(np.median(valid)) + + assert cone.median[0] == pytest.approx(manual_median, rel=1e-6), ( + f"vol_cone median {cone.median[0]:.6f} does not match manual median {manual_median:.6f}" + ) + + +class TestStrategyAnalyticsAccuracy: + @pytest.fixture(autouse=True) + def require_scipy(self): + pytest.importorskip("scipy") + + def test_put_call_parity_deviation_analytical(self): + """BSM call/put from scipy formulas fed into put_call_parity_deviation → < 1e-8.""" + from ferro_ta.analysis.options import put_call_parity_deviation + + S, K, r, q, T, sigma = 100.0, 100.0, 0.05, 0.02, 1.0, 0.20 + call = bsm_call(S, K, r, q, T, sigma) + put = bsm_put(S, K, r, q, T, sigma) + + dev = put_call_parity_deviation(call, put, S, K, r, T, carry=q) + assert abs(dev) < 1e-8, ( + f"put_call_parity_deviation for BSM-consistent prices: got {dev}, expected ~0" + ) + + def test_expected_move_known_value(self): + """S=100, iv=0.20, days=30, trading_days=252 → upper move ≈ 7.14.""" + from ferro_ta.analysis.options import expected_move + + S, iv, days, td = 100.0, 0.20, 30.0, 252.0 + lower, upper = expected_move(S, iv, days, td) + + # log-normal formula: S * (exp(sigma * sqrt(days/trading_days)) - 1) + expected_upper = S * (np.exp(iv * np.sqrt(days / td)) - 1.0) + expected_lower = S * (np.exp(-iv * np.sqrt(days / td)) - 1.0) + + assert upper == pytest.approx(expected_upper, rel=1e-6), ( + f"expected_move upper: got {upper:.4f}, expected {expected_upper:.4f}" + ) + assert lower == pytest.approx(expected_lower, rel=1e-6), ( + f"expected_move lower: got {lower:.4f}, expected {expected_lower:.4f}" + ) + # Numeric check: upper ≈ 7.14 + assert upper == pytest.approx(7.14, abs=0.05), ( + f"expected_move upper should be ~7.14, got {upper:.4f}" + ) diff --git a/ferro-ta-main/tests/unit/test_edge_cases.py b/ferro-ta-main/tests/unit/test_edge_cases.py new file mode 100644 index 0000000..238f392 --- /dev/null +++ b/ferro-ta-main/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/ferro-ta-main/tests/unit/test_ferro_ta.py b/ferro-ta-main/tests/unit/test_ferro_ta.py new file mode 100644 index 0000000..0ca39c8 --- /dev/null +++ b/ferro-ta-main/tests/unit/test_ferro_ta.py @@ -0,0 +1,2986 @@ +"""Tests for ferro_ta technical analysis indicators.""" + +import math + +import numpy as np +import pytest + +from ferro_ta import ( + ACOS, + # Volume + AD, + # Math Operators + ADD, + ADOSC, + ADX, + ADXR, + AROON, + ASIN, + ATAN, + # Volatility + ATR, + # Price transforms + AVGPRICE, + BBANDS, + CDL3BLACKCROWS, + CDL3INSIDE, + # Candlestick patterns + CDL3LINESTRIKE, + CDL3OUTSIDE, + CDL3STARSINSOUTH, + CDL3WHITESOLDIERS, + CDLABANDONEDBABY, + CDLADVANCEBLOCK, + CDLBELTHOLD, + CDLBREAKAWAY, + CDLCLOSINGMARUBOZU, + CDLCONCEALBABYSWALL, + CDLCOUNTERATTACK, + CDLDARKCLOUDCOVER, + # Patterns + CDLDOJI, + CDLDOJISTAR, + CDLDRAGONFLYDOJI, + CDLENGULFING, + CDLEVENINGDOJISTAR, + CDLGAPSIDESIDEWHITE, + CDLGRAVESTONEDOJI, + CDLHAMMER, + CDLHANGINGMAN, + CDLHARAMI, + CDLHARAMICROSS, + CDLHIGHWAVE, + CDLHIKKAKE, + CDLHIKKAKEMOD, + CDLHOMINGPIGEON, + CDLIDENTICAL3CROWS, + CDLINNECK, + CDLINVERTEDHAMMER, + CDLKICKING, + CDLKICKINGBYLENGTH, + CDLLADDERBOTTOM, + CDLLONGLEGGEDDOJI, + CDLLONGLINE, + CDLMARUBOZU, + CDLMATCHINGLOW, + CDLMATHOLD, + CDLMORNINGDOJISTAR, + CDLONNECK, + CDLPIERCING, + CDLRICKSHAWMAN, + CDLRISEFALL3METHODS, + CDLSEPARATINGLINES, + CDLSHOOTINGSTAR, + CDLSHORTLINE, + CDLSTALLEDPATTERN, + CDLSTICKSANDWICH, + CDLTAKURI, + CDLTASUKIGAP, + CDLTHRUSTING, + CDLTRISTAR, + CDLUNIQUE3RIVER, + CDLUPSIDEGAP2CROWS, + CDLXSIDEGAP3METHODS, + CEIL, + CMO, + CORREL, + COS, + COSH, + DEMA, + DIV, + DX, + EMA, + EXP, + FLOOR, + HT_DCPERIOD, + HT_DCPHASE, + HT_PHASOR, + HT_SINE, + # Cycle + HT_TRENDLINE, + HT_TRENDMODE, + LINEARREG, + LN, + LOG10, + MA, + MACD, + MACDEXT, + MACDFIX, + MAMA, + MAVP, + MAX, + MAXINDEX, + MEDPRICE, + MIDPOINT, + MIDPRICE, + MIN, + MININDEX, + MINUS_DI, + MINUS_DM, + # Momentum + MOM, + MULT, + NATR, + OBV, + PLUS_DI, + PLUS_DM, + ROC, + ROCP, + RSI, + SAR, + SAREXT, + SIN, + SINH, + SMA, + SQRT, + # Statistics + STDDEV, + STOCH, + STOCHRSI, + SUB, + SUM, + TAN, + TANH, + TEMA, + TRANGE, + TYPPRICE, + WCLPRICE, + WILLR, + # Overlap + WMA, +) + +# --------------------------------------------------------------------------- +# Shared fixture +# --------------------------------------------------------------------------- + +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, +) + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _nan_count(arr: np.ndarray) -> int: + return int(np.sum(np.isnan(arr))) + + +def _finite(arr: np.ndarray) -> np.ndarray: + return arr[~np.isnan(arr)] + + +# --------------------------------------------------------------------------- +# SMA +# --------------------------------------------------------------------------- + + +class TestSMA: + def test_output_length(self): + result = SMA(PRICES, timeperiod=3) + assert len(result) == len(PRICES) + + def test_leading_nans(self): + period = 5 + result = SMA(PRICES, timeperiod=period) + assert _nan_count(result) == period - 1 + + def test_values_correct(self): + prices = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = SMA(prices, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + assert math.isclose(result[2], 2.0) + assert math.isclose(result[3], 3.0) + assert math.isclose(result[4], 4.0) + + def test_accepts_python_list(self): + result = SMA([1.0, 2.0, 3.0, 4.0], timeperiod=2) + assert len(result) == 4 + + def test_default_period(self): + long_prices = np.arange(1.0, 51.0) + result = SMA(long_prices) # default period = 30 + assert _nan_count(result) == 29 + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + SMA(PRICES, timeperiod=0) + + def test_period_equals_length(self): + prices = np.array([1.0, 2.0, 3.0]) + result = SMA(prices, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + assert math.isclose(result[2], 2.0) + + +# --------------------------------------------------------------------------- +# EMA +# --------------------------------------------------------------------------- + + +class TestEMA: + def test_output_length(self): + result = EMA(PRICES, timeperiod=3) + assert len(result) == len(PRICES) + + def test_leading_nans(self): + period = 5 + result = EMA(PRICES, timeperiod=period) + assert _nan_count(result) == period - 1 + + def test_values_reasonable(self): + prices = np.array([10.0, 11.0, 12.0, 11.0, 10.0, 11.0, 12.0]) + result = EMA(prices, timeperiod=3) + finite = _finite(result) + assert len(finite) == len(prices) - 2 + # EMA should be a reasonable average-like value + assert all(8.0 <= v <= 14.0 for v in finite) + + def test_ema_differs_from_sma(self): + """EMA weights recent prices more — it must differ from SMA.""" + prices = np.array([1.0, 2.0, 3.0, 10.0, 11.0]) + ema_result = EMA(prices, timeperiod=3) + sma_result = SMA(prices, timeperiod=3) + # Both should be finite for the last value + assert not math.isclose(ema_result[-1], sma_result[-1], rel_tol=1e-9) + + +# --------------------------------------------------------------------------- +# RSI +# --------------------------------------------------------------------------- + + +class TestRSI: + def test_output_length(self): + result = RSI(PRICES, timeperiod=5) + assert len(result) == len(PRICES) + + def test_leading_nans(self): + period = 5 + result = RSI(PRICES, timeperiod=period) + assert _nan_count(result) == period + + def test_rsi_range(self): + result = RSI(PRICES, timeperiod=5) + finite = _finite(result) + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_constant_prices_rsi_50(self): + """For constant prices, RSI should be around 50 (no gains or losses).""" + prices = np.full(20, 50.0) + result = RSI(prices, timeperiod=5) + finite = _finite(result) + # With constant prices there are no changes, RSI is typically 50 or 100 + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + RSI(PRICES, timeperiod=0) + + +# --------------------------------------------------------------------------- +# MACD +# --------------------------------------------------------------------------- + + +class TestMACD: + def test_output_tuple_of_three(self): + result = MACD(PRICES) + assert isinstance(result, tuple) + assert len(result) == 3 + + def test_output_lengths_equal(self): + macd_line, signal, hist = MACD(PRICES) + assert len(macd_line) == len(PRICES) + assert len(signal) == len(PRICES) + assert len(hist) == len(PRICES) + + def test_histogram_is_macd_minus_signal(self): + """Histogram must equal MACD line minus signal line for valid indices.""" + prices = np.arange(1.0, 60.0) + macd_line, signal, hist = MACD( + prices, fastperiod=3, slowperiod=6, signalperiod=2 + ) + mask = ~(np.isnan(macd_line) | np.isnan(signal) | np.isnan(hist)) + assert np.allclose(hist[mask], macd_line[mask] - signal[mask], atol=1e-10) + + def test_fast_must_be_less_than_slow(self): + with pytest.raises(Exception): + MACD(PRICES, fastperiod=26, slowperiod=12) + + def test_all_nan_when_not_enough_data(self): + prices = np.arange(1.0, 6.0) # only 5 points + macd_line, signal, hist = MACD( + prices, fastperiod=3, slowperiod=4, signalperiod=2 + ) + # warmup = 4 + 2 - 2 = 4, so only index 4 might be valid + assert np.isnan(macd_line[0]) + + def test_default_periods(self): + prices = np.arange(1.0, 100.0) + macd_line, signal, hist = MACD(prices) + # MACD line is valid from slowperiod-1=25; signal from slowperiod+signalperiod-2=33 + assert all(np.isnan(macd_line[:25])) + assert any(~np.isnan(macd_line[25:])) + # Signal line starts at index 33 + assert all(np.isnan(signal[:33])) + assert any(~np.isnan(signal[33:])) + + +# --------------------------------------------------------------------------- +# Bollinger Bands +# --------------------------------------------------------------------------- + + +class TestBBANDS: + def test_output_tuple_of_three(self): + result = BBANDS(PRICES, timeperiod=5) + assert isinstance(result, tuple) + assert len(result) == 3 + + def test_output_lengths_equal(self): + upper, middle, lower = BBANDS(PRICES, timeperiod=5) + assert len(upper) == len(PRICES) + assert len(middle) == len(PRICES) + assert len(lower) == len(PRICES) + + def test_leading_nans(self): + period = 5 + upper, middle, lower = BBANDS(PRICES, timeperiod=period) + assert _nan_count(upper) == period - 1 + assert _nan_count(middle) == period - 1 + assert _nan_count(lower) == period - 1 + + def test_band_ordering(self): + """Upper >= middle >= lower for all valid values.""" + upper, middle, lower = BBANDS(PRICES, timeperiod=5) + mask = ~(np.isnan(upper) | np.isnan(middle) | np.isnan(lower)) + assert np.all(upper[mask] >= middle[mask]) + assert np.all(middle[mask] >= lower[mask]) + + def test_symmetric_bands(self): + """With equal nbdevup/nbdevdn, bands are symmetric around middle.""" + prices = np.array([10.0, 11.0, 12.0, 11.0, 10.0, 11.0, 12.0]) + upper, middle, lower = BBANDS(prices, timeperiod=3, nbdevup=2.0, nbdevdn=2.0) + mask = ~(np.isnan(upper) | np.isnan(lower)) + assert np.allclose( + upper[mask] - middle[mask], + middle[mask] - lower[mask], + atol=1e-10, + ) + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + BBANDS(PRICES, timeperiod=0) + + def test_accepts_python_list(self): + prices = [10.0, 11.0, 12.0, 11.0, 10.0, 11.0, 12.0] + upper, middle, lower = BBANDS(prices, timeperiod=3) + assert len(upper) == len(prices) + + +# --------------------------------------------------------------------------- +# Input validation shared tests +# --------------------------------------------------------------------------- + + +class TestInputValidation: + def test_2d_array_raises(self): + arr = np.array([[1.0, 2.0], [3.0, 4.0]]) + with pytest.raises(ValueError): + SMA(arr, timeperiod=2) + + def test_int_array_is_coerced(self): + """Integer arrays should be automatically cast to float64.""" + prices = np.array([10, 11, 12, 13, 14], dtype=np.int64) + result = SMA(prices, timeperiod=3) + assert result.dtype == np.float64 + + +# --------------------------------------------------------------------------- +# Shared fixtures for OHLCV tests +# --------------------------------------------------------------------------- + +OHLCV_PRICES = np.arange(1.0, 51.0) +OHLCV_HIGH = OHLCV_PRICES + 0.5 +OHLCV_LOW = OHLCV_PRICES - 0.5 +OHLCV_CLOSE = OHLCV_PRICES +OHLCV_OPEN = OHLCV_PRICES - 0.2 +OHLCV_VOLUME = np.ones(50) * 1000.0 + + +# --------------------------------------------------------------------------- +# Overlap Studies — new indicators +# --------------------------------------------------------------------------- + + +class TestWMA: + def test_output_length(self): + result = WMA(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = WMA(OHLCV_PRICES, 5) + assert _nan_count(result) == 4 + + def test_values_correct(self): + prices = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = WMA(prices, 3) + # WMA(3) at i=2: (1*1 + 2*2 + 3*3) / (1+2+3) = 14/6 + assert math.isclose(result[2], 14.0 / 6.0, rel_tol=1e-9) + + +class TestDEMA: + def test_output_length(self): + result = DEMA(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = DEMA(OHLCV_PRICES, 5) + assert _nan_count(result) == 2 * (5 - 1) + + +class TestTEMA: + def test_output_length(self): + result = TEMA(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = TEMA(OHLCV_PRICES, 5) + assert _nan_count(result) == 3 * (5 - 1) + + +class TestMACDFIX: + def test_output_tuple_of_three(self): + result = MACDFIX(OHLCV_PRICES) + assert isinstance(result, tuple) and len(result) == 3 + + def test_all_same_length(self): + m, s, h = MACDFIX(OHLCV_PRICES) + assert len(m) == len(OHLCV_PRICES) + assert len(s) == len(OHLCV_PRICES) + assert len(h) == len(OHLCV_PRICES) + + +class TestSAR: + def test_output_length(self): + result = SAR(OHLCV_HIGH, OHLCV_LOW) + assert len(result) == len(OHLCV_HIGH) + + def test_values_reasonable(self): + result = SAR(OHLCV_HIGH, OHLCV_LOW) + finite = _finite(result) + assert len(finite) > 0 + assert all(v > 0 for v in finite) + + +class TestMIDPOINT: + def test_output_length(self): + result = MIDPOINT(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = MIDPOINT(OHLCV_PRICES, 5) + assert _nan_count(result) == 4 + + +class TestMIDPRICE: + def test_output_length(self): + result = MIDPRICE(OHLCV_HIGH, OHLCV_LOW, 5) + assert len(result) == len(OHLCV_HIGH) + + +# --------------------------------------------------------------------------- +# Momentum Indicators — new indicators +# --------------------------------------------------------------------------- + + +class TestMOM: + def test_output_length(self): + result = MOM(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = MOM(OHLCV_PRICES, 5) + assert _nan_count(result) == 5 + + def test_values_correct(self): + prices = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = MOM(prices, 2) + assert math.isclose(result[2], 2.0) + assert math.isclose(result[3], 2.0) + + +class TestROC: + def test_output_length(self): + result = ROC(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = ROC(OHLCV_PRICES, 5) + assert _nan_count(result) == 5 + + def test_values_formula(self): + prices = np.array([10.0, 11.0, 12.0, 10.0, 11.0]) + result = ROC(prices, 2) + # ROC[4] = (11 - 12) / 12 * 100 + assert math.isclose(result[4], (11.0 - 12.0) / 12.0 * 100.0, rel_tol=1e-9) + + +class TestROCP: + def test_output_length(self): + result = ROCP(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_relation_to_roc(self): + """ROCP * 100 should equal ROC.""" + roc_result = ROC(OHLCV_PRICES, 5) + rocp_result = ROCP(OHLCV_PRICES, 5) + mask = ~(np.isnan(roc_result) | np.isnan(rocp_result)) + assert np.allclose(rocp_result[mask] * 100.0, roc_result[mask], atol=1e-10) + + +class TestWILLR: + def test_output_length(self): + result = WILLR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_range_correct(self): + result = WILLR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 5) + finite = _finite(result) + assert all(-100.0 <= v <= 0.0 for v in finite) + + +class TestAROON: + def test_output_tuple(self): + result = AROON(OHLCV_HIGH, OHLCV_LOW, 14) + assert isinstance(result, tuple) and len(result) == 2 + + def test_range_correct(self): + down, up = AROON(OHLCV_HIGH, OHLCV_LOW, 14) + down_finite = _finite(down) + up_finite = _finite(up) + assert all(0.0 <= v <= 100.0 for v in down_finite) + assert all(0.0 <= v <= 100.0 for v in up_finite) + + +class TestADX: + def test_output_length(self): + result = ADX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + assert len(result) == len(OHLCV_PRICES) + + def test_range_correct(self): + result = ADX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + finite = _finite(result) + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestCMO: + def test_output_length(self): + result = CMO(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_range_correct(self): + result = CMO(OHLCV_PRICES, 5) + finite = _finite(result) + assert all(-100.0 <= v <= 100.0 for v in finite) + + +# --------------------------------------------------------------------------- +# Volume Indicators +# --------------------------------------------------------------------------- + + +class TestOBV: + def test_output_length(self): + result = OBV(OHLCV_CLOSE, OHLCV_VOLUME) + assert len(result) == len(OHLCV_PRICES) + + def test_monotone_increasing(self): + """With always-rising prices, OBV should be non-decreasing.""" + result = OBV(OHLCV_CLOSE, OHLCV_VOLUME) + assert all(result[i] <= result[i + 1] for i in range(1, len(result) - 1)) + + +class TestAD: + def test_output_length(self): + result = AD(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, OHLCV_VOLUME) + assert len(result) == len(OHLCV_PRICES) + + +class TestADOSC: + def test_output_length(self): + result = ADOSC(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, OHLCV_VOLUME) + assert len(result) == len(OHLCV_PRICES) + + +# --------------------------------------------------------------------------- +# Volatility Indicators +# --------------------------------------------------------------------------- + + +class TestATR: + def test_output_length(self): + result = ATR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + assert len(result) == len(OHLCV_PRICES) + + def test_values_positive(self): + result = ATR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + finite = _finite(result) + assert all(v > 0 for v in finite) + + +class TestNATR: + def test_output_length(self): + result = NATR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + assert len(result) == len(OHLCV_PRICES) + + def test_values_positive(self): + result = NATR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + finite = _finite(result) + assert all(v > 0 for v in finite) + + +class TestTRANGE: + def test_output_length(self): + result = TRANGE(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + + def test_values_positive(self): + result = TRANGE(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert all(v > 0 for v in result) + + +# --------------------------------------------------------------------------- +# Statistic Functions +# --------------------------------------------------------------------------- + + +class TestSTDDEV: + def test_output_length(self): + result = STDDEV(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = STDDEV(OHLCV_PRICES, 5) + assert _nan_count(result) == 4 + + def test_constant_prices_zero_stddev(self): + prices = np.full(20, 100.0) + result = STDDEV(prices, 5) + finite = _finite(result) + assert all(math.isclose(v, 0.0, abs_tol=1e-10) for v in finite) + + +class TestLINEARREG: + def test_output_length(self): + result = LINEARREG(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_linear_data_matches_values(self): + """For perfectly linear data, LINEARREG endpoint should match the actual value.""" + result = LINEARREG(OHLCV_PRICES, 5) + finite = _finite(result) + expected = OHLCV_PRICES[len(OHLCV_PRICES) - len(finite) :] + assert np.allclose(finite, expected, atol=1e-10) + + +class TestCORREL: + def test_perfect_correlation(self): + result = CORREL(OHLCV_PRICES, OHLCV_PRICES, 10) + finite = _finite(result) + assert all(math.isclose(v, 1.0, abs_tol=1e-10) for v in finite) + + def test_range(self): + result = CORREL(OHLCV_PRICES, OHLCV_HIGH, 10) + finite = _finite(result) + assert all(-1.0 <= v <= 1.0 for v in finite) + + +# --------------------------------------------------------------------------- +# Price Transformations +# --------------------------------------------------------------------------- + + +class TestPriceTransforms: + def test_avgprice(self): + result = AVGPRICE(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + expected = (OHLCV_OPEN + OHLCV_HIGH + OHLCV_LOW + OHLCV_CLOSE) / 4.0 + assert np.allclose(result, expected, atol=1e-10) + + def test_medprice(self): + result = MEDPRICE(OHLCV_HIGH, OHLCV_LOW) + expected = (OHLCV_HIGH + OHLCV_LOW) / 2.0 + assert np.allclose(result, expected, atol=1e-10) + + def test_typprice(self): + result = TYPPRICE(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + expected = (OHLCV_HIGH + OHLCV_LOW + OHLCV_CLOSE) / 3.0 + assert np.allclose(result, expected, atol=1e-10) + + def test_wclprice(self): + result = WCLPRICE(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + expected = (OHLCV_HIGH + OHLCV_LOW + OHLCV_CLOSE * 2.0) / 4.0 + assert np.allclose(result, expected, atol=1e-10) + + +# --------------------------------------------------------------------------- +# Pattern Recognition +# --------------------------------------------------------------------------- + + +class TestPatternRecognition: + def test_cdldoji_output_values(self): + result = CDLDOJI(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (0, 100) for v in result) + + def test_cdlengulfing_output_values(self): + result = CDLENGULFING(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0, 100) for v in result) + + def test_cdlmarubozu_detects_full_body(self): + """A full-body candle with no shadows should be detected as marubozu.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([10.0]) + c = np.array([15.0]) + result = CDLMARUBOZU(o, h, l, c) + assert result[0] == 100 + + def test_cdldoji_detects_doji(self): + """A candle where open == close should be detected.""" + o = np.array([10.0]) + h = np.array([12.0]) + l = np.array([8.0]) + c = np.array([10.0]) + result = CDLDOJI(o, h, l, c) + assert result[0] == 100 + + def test_cdlhammer_detects_hammer(self): + """Long lower shadow, small body at top, tiny upper shadow.""" + # body = 0.5, range = 2.0, lower = 1.0 >= 2*0.5, upper = 0.5 <= 0.5 + o = np.array([8.0]) + h = np.array([9.0]) + l = np.array([7.0]) + c = np.array([8.5]) + result = CDLHAMMER(o, h, l, c) + assert result[0] == 100 + + def test_cdlshootingstar_detects_pattern(self): + """Long upper shadow, small body at bottom, tiny lower shadow.""" + o = np.array([8.5]) + h = np.array([11.0]) + l = np.array([8.0]) + c = np.array([8.0]) + result = CDLSHOOTINGSTAR(o, h, l, c) + assert result[0] == -100 + + +# --------------------------------------------------------------------------- +# New Overlap Indicators +# --------------------------------------------------------------------------- + +# Larger price series for indicators that need more data (MAMA, HT need 32+/63+ bars) +N_LONG = 200 +RNG_LONG = np.random.default_rng(123) +LONG_CLOSE = 50.0 + np.cumsum(RNG_LONG.standard_normal(N_LONG) * 0.5) +LONG_HIGH = LONG_CLOSE + RNG_LONG.uniform(0.1, 1.0, N_LONG) +LONG_LOW = LONG_CLOSE - RNG_LONG.uniform(0.1, 1.0, N_LONG) + + +class TestMA: + def test_ma_sma_matches_sma(self): + result_ma = MA(PRICES, timeperiod=5, matype=0) + result_sma = SMA(PRICES, timeperiod=5) + assert np.allclose(result_ma, result_sma, equal_nan=True) + + def test_ma_ema_matches_ema(self): + result_ma = MA(PRICES, timeperiod=5, matype=1) + result_ema = EMA(PRICES, timeperiod=5) + assert np.allclose(result_ma, result_ema, equal_nan=True) + + def test_ma_wma_matches_wma(self): + result_ma = MA(PRICES, timeperiod=5, matype=2) + result_wma = WMA(PRICES, timeperiod=5) + assert np.allclose(result_ma, result_wma, equal_nan=True) + + def test_ma_invalid_matype_raises(self): + with pytest.raises(Exception): + MA(PRICES, timeperiod=5, matype=99) + + def test_ma_output_length(self): + result = MA(PRICES, timeperiod=5, matype=0) + assert len(result) == len(PRICES) + + def test_ma_leading_nans(self): + """MA(matype=0, period=5) should have 4 leading NaNs.""" + result = MA(PRICES, timeperiod=5, matype=0) + assert _nan_count(result) == 4 # timeperiod - 1 leading NaNs + + +class TestMAVP: + def test_output_length(self): + periods = np.full(len(PRICES), 5.0) + result = MAVP(PRICES, periods) + assert len(result) == len(PRICES) + + def test_constant_period_matches_sma(self): + """MAVP with constant period should equal SMA with that period.""" + periods = np.full(len(PRICES), 5.0) + result = MAVP(PRICES, periods, minperiod=5, maxperiod=5) + expected = SMA(PRICES, timeperiod=5) + valid = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[valid], expected[valid], atol=1e-10) + + def test_mismatched_lengths_raises(self): + with pytest.raises(Exception): + MAVP(PRICES, np.array([5.0, 5.0])) + + +class TestMAMA: + def test_output_length(self): + mama_arr, fama_arr = MAMA(LONG_CLOSE) + assert len(mama_arr) == N_LONG + assert len(fama_arr) == N_LONG + + def test_leading_nans(self): + mama_arr, fama_arr = MAMA(LONG_CLOSE) + # First 32 values should be NaN + assert all(np.isnan(mama_arr[:32])) + assert all(np.isnan(fama_arr[:32])) + + def test_valid_values_finite(self): + mama_arr, fama_arr = MAMA(LONG_CLOSE) + valid = ~np.isnan(mama_arr) + assert np.all(np.isfinite(mama_arr[valid])) + assert np.all(np.isfinite(fama_arr[valid])) + + +class TestSAREXT: + def test_output_length(self): + result = SAREXT(LONG_HIGH, LONG_LOW) + assert len(result) == N_LONG + + def test_first_value_nan(self): + result = SAREXT(LONG_HIGH, LONG_LOW) + assert np.isnan(result[0]) + + def test_default_matches_sar(self): + """SAREXT with default params should be close to SAR.""" + sar_result = SAR(LONG_HIGH, LONG_LOW) + sarext_result = SAREXT(LONG_HIGH, LONG_LOW) + valid = ~np.isnan(sar_result) & ~np.isnan(sarext_result) + assert np.allclose(sar_result[valid], sarext_result[valid], atol=1e-10) + + +class TestMACDEXT: + def test_output_length(self): + m, s, h = MACDEXT(LONG_CLOSE) + assert len(m) == len(s) == len(h) == N_LONG + + def test_ema_matches_standard_macd(self): + """MACDEXT with EMA (matype=1) should produce valid output of correct shape. + + Note: MACDEXT uses a different EMA seeding strategy than the `ta` crate's + EMA (price at index period-1 vs. accumulated from index 0), so exact value + equivalence with MACD is not expected in the warmup period. + """ + m_ext, s_ext, h_ext = MACDEXT( + LONG_CLOSE, fastmatype=1, slowmatype=1, signalmatype=1 + ) + m_std, s_std, h_std = MACD(LONG_CLOSE) + # Both should have same length + assert len(m_ext) == len(m_std) + # Both should have valid (non-NaN) values at the same trailing region + valid_ext = ~np.isnan(m_ext) + valid_std = ~np.isnan(m_std) + # At least 50% of values should be valid for 200-bar series + assert valid_ext.sum() >= N_LONG // 2 + assert valid_std.sum() >= N_LONG // 2 + + def test_invalid_periods_raise(self): + with pytest.raises(Exception): + MACDEXT(LONG_CLOSE, fastperiod=26, slowperiod=12) # fast >= slow + + +# --------------------------------------------------------------------------- +# New Candlestick Patterns +# --------------------------------------------------------------------------- + + +class TestNewPatterns: + def test_cdl3blackcrows_output_values(self): + result = CDL3BLACKCROWS(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0) for v in result) + + def test_cdl3whitesoldiers_output_values(self): + result = CDL3WHITESOLDIERS(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (0, 100) for v in result) + + def test_cdl3inside_output_values(self): + result = CDL3INSIDE(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0, 100) for v in result) + + def test_cdl3outside_output_values(self): + result = CDL3OUTSIDE(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0, 100) for v in result) + + def test_cdlharami_detects_bearish(self): + """Prior large bullish, small bearish inside.""" + # Candle 1: bullish, large body (o=10, c=15) + # Candle 2: bearish (o > c), body inside candle 1 body [10, 15] + o = np.array([10.0, 12.5]) + h = np.array([15.0, 13.0]) + l = np.array([10.0, 11.5]) + c = np.array([15.0, 12.0]) # bearish: c=12.0 < o=12.5, body inside [10, 15] + result = CDLHARAMI(o, h, l, c) + assert result[1] == -100 + + def test_cdlharami_detects_bullish(self): + """Prior large bearish, small bullish inside.""" + o = np.array([15.0, 12.0]) + h = np.array([15.0, 13.0]) + l = np.array([10.0, 11.5]) + c = np.array([10.0, 12.5]) # bullish inside + result = CDLHARAMI(o, h, l, c) + assert result[1] == 100 + + def test_cdlharamicross_output_values(self): + result = CDLHARAMICROSS(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0, 100) for v in result) + + def test_cdldojistar_output_values(self): + result = CDLDOJISTAR(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0, 100) for v in result) + + def test_cdlmorningdojistar_output_values(self): + result = CDLMORNINGDOJISTAR(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (0, 100) for v in result) + + def test_cdleveningdojistar_output_values(self): + result = CDLEVENINGDOJISTAR(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0) for v in result) + + def test_cdl3blackcrows_detects_pattern(self): + """Three consecutive bearish candles, each opening in previous body.""" + # Three strong bearish candles + o = np.array([100.0, 95.0, 90.0]) + h = np.array([101.0, 97.0, 92.0]) + l = np.array([90.0, 85.0, 80.0]) + c = np.array([91.0, 86.0, 81.0]) # bearish, long body, closes near low + result = CDL3BLACKCROWS(o, h, l, c) + assert result[2] == -100 + + def test_cdl3whitesoldiers_detects_pattern(self): + """Three consecutive bullish candles, each opening in previous body.""" + o = np.array([80.0, 86.0, 92.0]) + h = np.array([92.0, 98.0, 104.0]) + l = np.array([79.0, 85.0, 91.0]) + c = np.array([91.0, 97.0, 103.0]) # bullish, long body, closes near high + result = CDL3WHITESOLDIERS(o, h, l, c) + assert result[2] == 100 + + +# --------------------------------------------------------------------------- +# Cycle Indicators +# --------------------------------------------------------------------------- + + +class TestHilbertTransform: + def test_ht_trendline_output_length(self): + result = HT_TRENDLINE(LONG_CLOSE) + assert len(result) == N_LONG + + def test_ht_trendline_leading_nans(self): + result = HT_TRENDLINE(LONG_CLOSE) + assert all(np.isnan(result[:63])) + + def test_ht_trendline_valid_values(self): + result = HT_TRENDLINE(LONG_CLOSE) + valid = ~np.isnan(result) + assert valid.any() + assert np.all(np.isfinite(result[valid])) + + def test_ht_dcperiod_output_length(self): + result = HT_DCPERIOD(LONG_CLOSE) + assert len(result) == N_LONG + + def test_ht_dcperiod_values_in_range(self): + """Dominant cycle period should be between 6 and 50.""" + result = HT_DCPERIOD(LONG_CLOSE) + valid = ~np.isnan(result) + assert valid.any() + assert np.all(result[valid] >= 6.0) + assert np.all(result[valid] <= 50.0) + + def test_ht_dcphase_output_length(self): + result = HT_DCPHASE(LONG_CLOSE) + assert len(result) == N_LONG + + def test_ht_phasor_returns_two_arrays(self): + inphase, quad = HT_PHASOR(LONG_CLOSE) + assert len(inphase) == N_LONG + assert len(quad) == N_LONG + + def test_ht_phasor_leading_nans(self): + inphase, quad = HT_PHASOR(LONG_CLOSE) + assert all(np.isnan(inphase[:63])) + assert all(np.isnan(quad[:63])) + + def test_ht_sine_returns_two_arrays(self): + sine, lead = HT_SINE(LONG_CLOSE) + assert len(sine) == N_LONG + assert len(lead) == N_LONG + + def test_ht_sine_values_in_range(self): + """Sine values must be in [-1, 1].""" + sine, lead = HT_SINE(LONG_CLOSE) + valid = ~np.isnan(sine) + assert valid.any() + assert np.all(np.abs(sine[valid]) <= 1.0 + 1e-9) + assert np.all(np.abs(lead[valid]) <= 1.0 + 1e-9) + + def test_ht_trendmode_output_length(self): + result = HT_TRENDMODE(LONG_CLOSE) + assert len(result) == N_LONG + + def test_ht_trendmode_values_binary(self): + """Trend mode must be 0 or 1.""" + result = HT_TRENDMODE(LONG_CLOSE) + assert all(v in (0, 1) for v in result) + + def test_short_series_returns_all_nans(self): + """Series shorter than lookback should return all NaN.""" + short = np.arange(1.0, 10.0) + result = HT_TRENDLINE(short) + assert all(np.isnan(result)) + + +# --------------------------------------------------------------------------- +# New Pattern Recognition Tests (43 patterns) +# --------------------------------------------------------------------------- + + +class TestNewPatterns: + """Basic output-length and value-set checks for 43 new patterns.""" + + O = OHLCV_OPEN + H = OHLCV_HIGH + L = OHLCV_LOW + C = OHLCV_CLOSE + N = len(OHLCV_PRICES) + + # -- CDL3LINESTRIKE ------------------------------------------------------- + def test_cdl3linestrike_length(self): + r = CDL3LINESTRIKE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdl3linestrike_values(self): + r = CDL3LINESTRIKE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdl3linestrike_detects_bearish(self): + """3 bullish candles then a bearish engulfing all three.""" + o = np.array([10.0, 11.0, 12.0, 16.0]) + h = np.array([11.5, 12.5, 13.5, 16.5]) + l = np.array([9.5, 10.5, 11.5, 9.0]) + c = np.array([11.0, 12.0, 13.0, 9.5]) # bearish closes below first open + r = CDL3LINESTRIKE(o, h, l, c) + assert r[3] == -100 + + # -- CDL3STARSINSOUTH ----------------------------------------------------- + def test_cdl3starsinsouth_length(self): + r = CDL3STARSINSOUTH(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdl3starsinsouth_values(self): + r = CDL3STARSINSOUTH(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLABANDONEDBABY ----------------------------------------------------- + def test_cdlabandonedbaby_length(self): + r = CDLABANDONEDBABY(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlabandonedbaby_values(self): + r = CDLABANDONEDBABY(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlabandonedbaby_detects_bullish(self): + """Large bearish, doji gaps down (h_doji < l_prior), large bullish gaps up.""" + o = np.array([20.0, 9.0, 12.0]) + h = np.array([21.0, 9.1, 20.0]) + l = np.array([11.0, 8.9, 11.5]) + c = np.array( + [12.0, 9.0, 19.0] + ) # doji gaps below l[0]=11, bullish gaps above h[1]=9.1 + r = CDLABANDONEDBABY(o, h, l, c) + assert r[2] == 100 + + # -- CDLADVANCEBLOCK ------------------------------------------------------ + def test_cdladvanceblock_length(self): + r = CDLADVANCEBLOCK(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdladvanceblock_values(self): + r = CDLADVANCEBLOCK(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLBELTHOLD ---------------------------------------------------------- + def test_cdlbelthold_length(self): + r = CDLBELTHOLD(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlbelthold_values(self): + r = CDLBELTHOLD(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlbelthold_detects_bullish(self): + """Bullish candle opening at its low.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([10.0]) # open == low + c = np.array([14.5]) + r = CDLBELTHOLD(o, h, l, c) + assert r[0] == 100 + + def test_cdlbelthold_detects_bearish(self): + """Bearish candle opening at its high.""" + o = np.array([15.0]) + h = np.array([15.0]) # open == high + l = np.array([10.0]) + c = np.array([10.5]) + r = CDLBELTHOLD(o, h, l, c) + assert r[0] == -100 + + # -- CDLBREAKAWAY --------------------------------------------------------- + def test_cdlbreakaway_length(self): + r = CDLBREAKAWAY(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlbreakaway_values(self): + r = CDLBREAKAWAY(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLCLOSINGMARUBOZU --------------------------------------------------- + def test_cdlclosingmarubozu_length(self): + r = CDLCLOSINGMARUBOZU(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlclosingmarubozu_values(self): + r = CDLCLOSINGMARUBOZU(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlclosingmarubozu_detects_bullish(self): + """Bullish closing marubozu: close == high.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([9.0]) + c = np.array([15.0]) # close == high, no upper shadow + r = CDLCLOSINGMARUBOZU(o, h, l, c) + assert r[0] == 100 + + def test_cdlclosingmarubozu_detects_bearish(self): + """Bearish closing marubozu: close == low.""" + o = np.array([15.0]) + h = np.array([16.0]) + l = np.array([10.0]) + c = np.array([10.0]) # close == low, no lower shadow + r = CDLCLOSINGMARUBOZU(o, h, l, c) + assert r[0] == -100 + + # -- CDLCONCEALBABYSWALL -------------------------------------------------- + def test_cdlconcealbabyswall_length(self): + r = CDLCONCEALBABYSWALL(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlconcealbabyswall_values(self): + r = CDLCONCEALBABYSWALL(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLCOUNTERATTACK ----------------------------------------------------- + def test_cdlcounterattack_length(self): + r = CDLCOUNTERATTACK(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlcounterattack_values(self): + r = CDLCOUNTERATTACK(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLDARKCLOUDCOVER ---------------------------------------------------- + def test_cdldarkcloudcover_length(self): + r = CDLDARKCLOUDCOVER(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdldarkcloudcover_values(self): + r = CDLDARKCLOUDCOVER(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + def test_cdldarkcloudcover_detects_pattern(self): + """Bearish candle opening above prior high and closing below midpoint.""" + o = np.array([10.0, 16.0]) + h = np.array([15.0, 17.0]) + l = np.array([9.5, 11.0]) + c = np.array([14.0, 11.5]) # bearish, closes below midpoint of (10,14) + r = CDLDARKCLOUDCOVER(o, h, l, c) + assert r[1] == -100 + + # -- CDLDRAGONFLYDOJI ----------------------------------------------------- + def test_cdldragonflydoji_length(self): + r = CDLDRAGONFLYDOJI(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdldragonflydoji_values(self): + r = CDLDRAGONFLYDOJI(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdldragonflydoji_detects_pattern(self): + """Open ≈ close ≈ high with long lower shadow.""" + o = np.array([15.0]) + h = np.array([15.1]) + l = np.array([10.0]) + c = np.array([15.0]) + r = CDLDRAGONFLYDOJI(o, h, l, c) + assert r[0] == 100 + + # -- CDLGAPSIDESIDEWHITE -------------------------------------------------- + def test_cdlgapsidesidewhite_length(self): + r = CDLGAPSIDESIDEWHITE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlgapsidesidewhite_values(self): + r = CDLGAPSIDESIDEWHITE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLGRAVESTONEDOJI ---------------------------------------------------- + def test_cdlgravestonedoji_length(self): + r = CDLGRAVESTONEDOJI(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlgravestonedoji_values(self): + r = CDLGRAVESTONEDOJI(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + def test_cdlgravestonedoji_detects_pattern(self): + """Open ≈ close ≈ low with long upper shadow.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([9.9]) + c = np.array([10.0]) + r = CDLGRAVESTONEDOJI(o, h, l, c) + assert r[0] == -100 + + # -- CDLHANGINGMAN -------------------------------------------------------- + def test_cdlhangingman_length(self): + r = CDLHANGINGMAN(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhangingman_values(self): + r = CDLHANGINGMAN(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + def test_cdlhangingman_detects_pattern(self): + """Same shape as hammer but returns -100.""" + o = np.array([14.0]) + h = np.array([15.0]) + l = np.array([10.0]) + c = np.array([14.5]) + r = CDLHANGINGMAN(o, h, l, c) + assert r[0] == -100 + + # -- CDLHIGHWAVE ---------------------------------------------------------- + def test_cdlhighwave_length(self): + r = CDLHIGHWAVE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhighwave_values(self): + r = CDLHIGHWAVE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlhighwave_detects_pattern(self): + """Small body with very long shadows.""" + o = np.array([12.4]) + h = np.array([20.0]) + l = np.array([5.0]) + c = np.array([12.6]) # body=0.2, range=15, upper=7.6, lower=7.4 + r = CDLHIGHWAVE(o, h, l, c) + assert r[0] != 0 + + # -- CDLHIKKAKE ----------------------------------------------------------- + def test_cdlhikkake_length(self): + r = CDLHIKKAKE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhikkake_values(self): + r = CDLHIKKAKE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLHIKKAKEMOD -------------------------------------------------------- + def test_cdlhikkakemod_length(self): + r = CDLHIKKAKEMOD(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhikkakemod_values(self): + r = CDLHIKKAKEMOD(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLHOMINGPIGEON ------------------------------------------------------ + def test_cdlhomingpigeon_length(self): + r = CDLHOMINGPIGEON(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhomingpigeon_values(self): + r = CDLHOMINGPIGEON(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlhomingpigeon_detects_pattern(self): + """2 bearish candles, second entirely within first body.""" + o = np.array([20.0, 17.0]) + h = np.array([20.5, 17.5]) + l = np.array([10.0, 13.0]) + c = np.array([11.0, 14.0]) # both bearish, second within first body + r = CDLHOMINGPIGEON(o, h, l, c) + assert r[1] == 100 + + # -- CDLIDENTICAL3CROWS --------------------------------------------------- + def test_cdlidentical3crows_length(self): + r = CDLIDENTICAL3CROWS(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlidentical3crows_values(self): + r = CDLIDENTICAL3CROWS(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLINNECK ------------------------------------------------------------ + def test_cdlinneck_length(self): + r = CDLINNECK(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlinneck_values(self): + r = CDLINNECK(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLINVERTEDHAMMER ---------------------------------------------------- + def test_cdlinvertedhammer_length(self): + r = CDLINVERTEDHAMMER(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlinvertedhammer_values(self): + r = CDLINVERTEDHAMMER(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlinvertedhammer_detects_pattern(self): + """Small body at bottom, long upper shadow.""" + o = np.array([10.5]) + h = np.array([15.0]) + l = np.array([10.0]) + c = np.array([11.0]) # body=0.5, upper=4.0, lower=0.5 + r = CDLINVERTEDHAMMER(o, h, l, c) + assert r[0] == 100 + + # -- CDLKICKING ----------------------------------------------------------- + def test_cdlkicking_length(self): + r = CDLKICKING(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlkicking_values(self): + r = CDLKICKING(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlkicking_detects_bullish(self): + """Bearish marubozu then bullish marubozu with gap up.""" + o = np.array([15.0, 18.0]) + h = np.array([15.0, 23.0]) # bearish: open==high; bullish: close==high + l = np.array([10.0, 18.0]) # bearish: close==low; bullish: open==low + c = np.array([10.0, 23.0]) + r = CDLKICKING(o, h, l, c) + assert r[1] == 100 + + # -- CDLKICKINGBYLENGTH --------------------------------------------------- + def test_cdlkickingbylength_length(self): + r = CDLKICKINGBYLENGTH(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlkickingbylength_values(self): + r = CDLKICKINGBYLENGTH(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLLADDERBOTTOM ------------------------------------------------------ + def test_cdlladderbottom_length(self): + r = CDLLADDERBOTTOM(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlladderbottom_values(self): + r = CDLLADDERBOTTOM(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLLONGLEGGEDDOJI ---------------------------------------------------- + def test_cdllongleggeddoji_length(self): + r = CDLLONGLEGGEDDOJI(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdllongleggeddoji_values(self): + r = CDLLONGLEGGEDDOJI(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdllongleggeddoji_detects_pattern(self): + """Doji with long upper and lower shadows.""" + o = np.array([12.5]) + h = np.array([20.0]) + l = np.array([5.0]) + c = np.array([12.5]) # body=0, range=15, doji with both long shadows + r = CDLLONGLEGGEDDOJI(o, h, l, c) + assert r[0] == 100 + + # -- CDLLONGLINE ---------------------------------------------------------- + def test_cdllongline_length(self): + r = CDLLONGLINE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdllongline_values(self): + r = CDLLONGLINE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdllongline_detects_bullish(self): + """Long body >= 70% of range.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([9.5]) + c = np.array([15.0]) # body=5, range=5.5 => body/range=0.91 + r = CDLLONGLINE(o, h, l, c) + assert r[0] == 100 + + # -- CDLMATCHINGLOW ------------------------------------------------------- + def test_cdlmatchinglow_length(self): + r = CDLMATCHINGLOW(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlmatchinglow_values(self): + r = CDLMATCHINGLOW(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlmatchinglow_detects_pattern(self): + """Two bearish candles with equal closes.""" + o = np.array([15.0, 14.0]) + h = np.array([15.5, 14.5]) + l = np.array([10.0, 10.0]) + c = np.array([10.0, 10.0]) # equal closes, both bearish + r = CDLMATCHINGLOW(o, h, l, c) + assert r[1] == 100 + + # -- CDLMATHOLD ----------------------------------------------------------- + def test_cdlmathold_length(self): + r = CDLMATHOLD(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlmathold_values(self): + r = CDLMATHOLD(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLONNECK ------------------------------------------------------------ + def test_cdlonneck_length(self): + r = CDLONNECK(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlonneck_values(self): + r = CDLONNECK(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLPIERCING ---------------------------------------------------------- + def test_cdlpiercing_length(self): + r = CDLPIERCING(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlpiercing_values(self): + r = CDLPIERCING(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlpiercing_detects_pattern(self): + """Bearish then bullish that opens below prior low and closes above midpoint.""" + o = np.array([14.0, 9.0]) + h = np.array([15.0, 13.0]) + l = np.array([10.0, 8.5]) + c = np.array([10.5, 12.5]) # closes above midpoint of (14,10.5)=12.25 + r = CDLPIERCING(o, h, l, c) + assert r[1] == 100 + + # -- CDLRICKSHAWMAN ------------------------------------------------------- + def test_cdlrickshawman_length(self): + r = CDLRICKSHAWMAN(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlrickshawman_values(self): + r = CDLRICKSHAWMAN(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLRISEFALL3METHODS -------------------------------------------------- + def test_cdlrisefall3methods_length(self): + r = CDLRISEFALL3METHODS(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlrisefall3methods_values(self): + r = CDLRISEFALL3METHODS(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLSEPARATINGLINES --------------------------------------------------- + def test_cdlseparatinglines_length(self): + r = CDLSEPARATINGLINES(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlseparatinglines_values(self): + r = CDLSEPARATINGLINES(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLSHORTLINE --------------------------------------------------------- + def test_cdlshortline_length(self): + r = CDLSHORTLINE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlshortline_values(self): + r = CDLSHORTLINE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlshortline_detects_bullish(self): + """Short bullish body <= 30% of range.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([9.0]) + c = np.array([11.0]) # body=1, range=6 => body/range=0.17 + r = CDLSHORTLINE(o, h, l, c) + assert r[0] == 100 + + # -- CDLSTALLEDPATTERN ---------------------------------------------------- + def test_cdlstalledpattern_length(self): + r = CDLSTALLEDPATTERN(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlstalledpattern_values(self): + r = CDLSTALLEDPATTERN(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLSTICKSANDWICH ----------------------------------------------------- + def test_cdlsticksandwich_length(self): + r = CDLSTICKSANDWICH(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlsticksandwich_values(self): + r = CDLSTICKSANDWICH(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlsticksandwich_detects_pattern(self): + """Bearish, bullish in middle, bearish with same close as first.""" + o = np.array([15.0, 10.5, 14.0]) + h = np.array([15.5, 14.5, 14.5]) + l = np.array([10.0, 10.0, 10.0]) + c = np.array([10.0, 14.0, 10.0]) # first and third close at 10.0 + r = CDLSTICKSANDWICH(o, h, l, c) + assert r[2] == 100 + + # -- CDLTAKURI ------------------------------------------------------------ + def test_cdltakuri_length(self): + r = CDLTAKURI(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdltakuri_values(self): + r = CDLTAKURI(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdltakuri_detects_pattern(self): + """Very long lower shadow >= 3x body, open near high.""" + o = np.array([15.0]) + h = np.array([15.2]) + l = np.array([10.0]) + c = np.array([15.1]) # body=0.1, lower=5.0, lower>=3*body + r = CDLTAKURI(o, h, l, c) + assert r[0] == 100 + + # -- CDLTASUKIGAP --------------------------------------------------------- + def test_cdltasukigap_length(self): + r = CDLTASUKIGAP(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdltasukigap_values(self): + r = CDLTASUKIGAP(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLTHRUSTING --------------------------------------------------------- + def test_cdlthrusting_length(self): + r = CDLTHRUSTING(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlthrusting_values(self): + r = CDLTHRUSTING(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLTRISTAR ----------------------------------------------------------- + def test_cdltristar_length(self): + r = CDLTRISTAR(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdltristar_values(self): + r = CDLTRISTAR(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLUNIQUE3RIVER ------------------------------------------------------ + def test_cdlunique3river_length(self): + r = CDLUNIQUE3RIVER(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlunique3river_values(self): + r = CDLUNIQUE3RIVER(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLUPSIDEGAP2CROWS --------------------------------------------------- + def test_cdlupsidegap2crows_length(self): + r = CDLUPSIDEGAP2CROWS(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlupsidegap2crows_values(self): + r = CDLUPSIDEGAP2CROWS(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLXSIDEGAP3METHODS -------------------------------------------------- + def test_cdlxsidegap3methods_length(self): + r = CDLXSIDEGAP3METHODS(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlxsidegap3methods_values(self): + r = CDLXSIDEGAP3METHODS(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlxsidegap3methods_detects_bullish(self): + """Upside gap three methods: gap up bullish, bearish fills gap.""" + o = np.array([10.0, 12.0, 11.5]) + h = np.array([10.5, 13.0, 12.0]) + l = np.array([9.5, 11.5, 10.0]) + c = np.array([10.0, 12.5, 10.5]) # gap up then partial fill + r = CDLXSIDEGAP3METHODS(o, h, l, c) + assert r[2] in (0, 100) # may or may not detect depending on threshold + + +# --------------------------------------------------------------------------- +# Math Operators & Math Transforms +# --------------------------------------------------------------------------- + + +class TestMathOperators: + A = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + B = np.array([2.0, 2.0, 2.0, 2.0, 2.0]) + + def test_add(self): + r = ADD(self.A, self.B) + assert np.allclose(r, [3, 4, 5, 6, 7]) + + def test_sub(self): + r = SUB(self.A, self.B) + assert np.allclose(r, [-1, 0, 1, 2, 3]) + + def test_mult(self): + r = MULT(self.A, self.B) + assert np.allclose(r, [2, 4, 6, 8, 10]) + + def test_div(self): + r = DIV(self.A, self.B) + assert np.allclose(r, [0.5, 1, 1.5, 2, 2.5]) + + def test_sum_rolling(self): + r = SUM(self.A, timeperiod=3) + assert np.isnan(r[0]) and np.isnan(r[1]) + assert math.isclose(r[2], 6.0) + assert math.isclose(r[3], 9.0) + assert math.isclose(r[4], 12.0) + + def test_max_rolling(self): + r = MAX(self.A, timeperiod=3) + assert np.isnan(r[0]) and np.isnan(r[1]) + assert math.isclose(r[2], 3.0) + assert math.isclose(r[4], 5.0) + + def test_min_rolling(self): + r = MIN(self.A, timeperiod=3) + assert np.isnan(r[0]) and np.isnan(r[1]) + assert math.isclose(r[2], 1.0) + assert math.isclose(r[4], 3.0) + + def test_maxindex(self): + r = MAXINDEX(self.A, timeperiod=3) + assert r[0] == -1 and r[1] == -1 + assert r[2] == 2 # max at index 2 (value 3) + assert r[4] == 4 # max at index 4 (value 5) + + def test_minindex(self): + r = MININDEX(self.A, timeperiod=3) + assert r[0] == -1 and r[1] == -1 + assert r[2] == 0 # min at index 0 (value 1) + assert r[4] == 2 # min at index 2 (value 3) + + def test_sum_output_length(self): + r = SUM(self.A, timeperiod=2) + assert len(r) == len(self.A) + + def test_max_output_length(self): + r = MAX(self.A, timeperiod=2) + assert len(r) == len(self.A) + + +class TestMathTransforms: + X = np.array([0.0, 0.5, 1.0]) + POS = np.array([1.0, 2.0, 4.0]) + + def test_acos(self): + r = ACOS(self.X) + assert np.allclose(r, np.arccos(self.X)) + + def test_asin(self): + r = ASIN(self.X) + assert np.allclose(r, np.arcsin(self.X)) + + def test_atan(self): + r = ATAN(self.X) + assert np.allclose(r, np.arctan(self.X)) + + def test_ceil(self): + r = CEIL(np.array([1.1, 2.5, 3.9])) + assert np.allclose(r, [2.0, 3.0, 4.0]) + + def test_floor(self): + r = FLOOR(np.array([1.1, 2.5, 3.9])) + assert np.allclose(r, [1.0, 2.0, 3.0]) + + def test_cos(self): + r = COS(self.X) + assert np.allclose(r, np.cos(self.X)) + + def test_sin(self): + r = SIN(self.X) + assert np.allclose(r, np.sin(self.X)) + + def test_tan(self): + r = TAN(self.X) + assert np.allclose(r, np.tan(self.X)) + + def test_exp(self): + r = EXP(self.X) + assert np.allclose(r, np.exp(self.X)) + + def test_ln(self): + r = LN(self.POS) + assert np.allclose(r, np.log(self.POS)) + + def test_log10(self): + r = LOG10(self.POS) + assert np.allclose(r, np.log10(self.POS)) + + def test_sqrt(self): + r = SQRT(self.POS) + assert np.allclose(r, np.sqrt(self.POS)) + + def test_sinh(self): + r = SINH(self.X) + assert np.allclose(r, np.sinh(self.X)) + + def test_cosh(self): + r = COSH(self.X) + assert np.allclose(r, np.cosh(self.X)) + + def test_tanh(self): + r = TANH(self.X) + assert np.allclose(r, np.tanh(self.X)) + + def test_accepts_list_input(self): + r = SQRT([1.0, 4.0, 9.0]) + assert np.allclose(r, [1.0, 2.0, 3.0]) + + +# --------------------------------------------------------------------------- +# Pandas Series / DataFrame API +# --------------------------------------------------------------------------- + + +class TestPandasAPI: + """Verify that pandas.Series inputs are transparently supported.""" + + pd = pytest.importorskip("pandas") + + @pytest.fixture(autouse=True) + def prices_series(self): + import pandas as pd + + idx = pd.date_range("2024-01-01", periods=20) + self.close_s = pd.Series(np.arange(1.0, 21.0), index=idx) + self.open_s = pd.Series(np.arange(1.0, 21.0) - 0.2, index=idx) + self.high_s = pd.Series(np.arange(1.0, 21.0) + 0.5, index=idx) + self.low_s = pd.Series(np.arange(1.0, 21.0) - 0.5, index=idx) + + def test_sma_returns_series(self): + import pandas as pd + + r = SMA(self.close_s, timeperiod=5) + assert isinstance(r, pd.Series) + + def test_sma_index_preserved(self): + r = SMA(self.close_s, timeperiod=5) + assert list(r.index) == list(self.close_s.index) + + def test_sma_values_match_numpy(self): + np_result = SMA(self.close_s.to_numpy(), timeperiod=5) + pd_result = SMA(self.close_s, timeperiod=5) + assert np.allclose(np_result, pd_result.to_numpy(), equal_nan=True) + + def test_ema_returns_series_with_index(self): + import pandas as pd + + r = EMA(self.close_s, timeperiod=5) + assert isinstance(r, pd.Series) + assert list(r.index) == list(self.close_s.index) + + def test_rsi_returns_series(self): + import pandas as pd + + long_s = pd.concat( + [ + self.close_s, + pd.Series( + np.arange(21.0, 41.0), index=pd.date_range("2024-01-21", periods=20) + ), + ] + ) + r = RSI(long_s, timeperiod=10) + assert isinstance(r, pd.Series) + assert len(r) == len(long_s) + + def test_bbands_returns_tuple_of_series(self): + import pandas as pd + + upper, mid, lower = BBANDS(self.close_s, timeperiod=5) + assert isinstance(upper, pd.Series) + assert isinstance(mid, pd.Series) + assert isinstance(lower, pd.Series) + assert list(upper.index) == list(self.close_s.index) + + def test_macd_returns_tuple_of_series(self): + import pandas as pd + + close_long = self.pd.Series(np.arange(1.0, 101.0)) + m, s, h = MACD(close_long) + assert isinstance(m, pd.Series) + assert isinstance(s, pd.Series) + assert isinstance(h, pd.Series) + + def test_pattern_returns_series(self): + import pandas as pd + + r = CDLDOJI(self.open_s, self.high_s, self.low_s, self.close_s) + assert isinstance(r, pd.Series) + assert list(r.index) == list(self.close_s.index) + + def test_atr_returns_series(self): + import pandas as pd + + r = ATR(self.high_s, self.low_s, self.close_s, timeperiod=5) + assert isinstance(r, pd.Series) + + def test_numpy_input_unaffected(self): + """Passing plain numpy arrays still returns numpy arrays.""" + arr = np.arange(1.0, 21.0) + r = SMA(arr, timeperiod=5) + assert isinstance(r, np.ndarray) + + def test_math_add_with_series(self): + import pandas as pd + + a = pd.Series([1.0, 2.0, 3.0]) + b = pd.Series([4.0, 5.0, 6.0]) + r = ADD(a, b) + assert isinstance(r, pd.Series) + assert np.allclose(r.to_numpy(), [5, 7, 9]) + + +# --------------------------------------------------------------------------- +# Pandas DataFrame OHLCV contract (get_ohlcv + configurable column names) +# --------------------------------------------------------------------------- + + +class TestPandasDataFrameOHLCV: + """DataFrame with OHLCV columns: get_ohlcv, default and custom column names, index preservation.""" + + pd = pytest.importorskip("pandas") + + @pytest.fixture(autouse=True) + def df_default_columns(self): + """DataFrame with default column names open, high, low, close, volume.""" + import pandas as pd + + n = 30 + idx = pd.date_range("2024-01-01", periods=n, freq="D") + close = np.arange(1.0, n + 1.0, dtype=float) + self.df_default = pd.DataFrame( + { + "open": close - 0.2, + "high": close + 0.5, + "low": close - 0.5, + "close": close, + "volume": np.full(n, 1000.0), + }, + index=idx, + ) + return None + + @pytest.fixture + def df_custom_columns(self): + """DataFrame with custom column names (Open, High, Low, Close).""" + import pandas as pd + + n = 30 + idx = pd.date_range("2024-02-01", periods=n, freq="D") + close = np.arange(10.0, n + 10.0, dtype=float) + return pd.DataFrame( + { + "Open": close - 0.2, + "High": close + 0.5, + "Low": close - 0.5, + "Close": close, + }, + index=idx, + ) + + def test_get_ohlcv_default_columns(self): + """get_ohlcv with default column names returns (o, h, l, c, v) with index.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + assert list(o.index) == list(self.df_default.index) + np.testing.assert_array_almost_equal(c, self.df_default["close"].to_numpy()) + + def test_get_ohlcv_custom_columns(self, df_custom_columns): + """get_ohlcv with custom column names.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv( + df_custom_columns, + open_col="Open", + high_col="High", + low_col="Low", + close_col="Close", + volume_col=None, + ) + assert len(c) == len(df_custom_columns) + np.testing.assert_array_almost_equal(c, df_custom_columns["Close"].to_numpy()) + + def test_dataframe_ohlcv_overlap_sma(self): + """Overlap (SMA): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = SMA(c, timeperiod=5) + r_numpy = SMA(self.df_default["close"].to_numpy(), timeperiod=5) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_momentum_rsi(self): + """Momentum (RSI): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = RSI(c, timeperiod=5) + r_numpy = RSI(self.df_default["close"].to_numpy(), timeperiod=5) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_volatility_atr(self, df_custom_columns): + """Volatility (ATR): custom column names, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv( + df_custom_columns, + open_col="Open", + high_col="High", + low_col="Low", + close_col="Close", + volume_col=None, + ) + r_series = ATR(h, l, c, timeperiod=5) + r_numpy = ATR( + df_custom_columns["High"].to_numpy(), + df_custom_columns["Low"].to_numpy(), + df_custom_columns["Close"].to_numpy(), + timeperiod=5, + ) + assert list(r_series.index) == list(df_custom_columns.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_pattern(self): + """Pattern (CDLDOJI): DataFrame via get_ohlcv, index preserved.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = CDLDOJI(o, h, l, c) + r_numpy = CDLDOJI( + self.df_default["open"].to_numpy(), + self.df_default["high"].to_numpy(), + self.df_default["low"].to_numpy(), + self.df_default["close"].to_numpy(), + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_array_equal(r_series.to_numpy(), r_numpy) + + def test_dataframe_ohlcv_cycle_ht_trendline(self): + """Cycle (HT_TRENDLINE): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + import pandas as pd + + n = 100 + idx = pd.date_range("2024-03-01", periods=n, freq="D") + close_arr = np.arange(1.0, n + 1.0, dtype=float) + df = pd.DataFrame( + { + "open": close_arr - 0.2, + "high": close_arr + 0.5, + "low": close_arr - 0.5, + "close": close_arr, + "volume": np.full(n, 1000.0), + }, + index=idx, + ) + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(df) + r_series = HT_TRENDLINE(c) + r_numpy = HT_TRENDLINE(close_arr) + assert list(r_series.index) == list(idx) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_statistic_stddev(self): + """Statistic (STDDEV): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = STDDEV(c, timeperiod=5) + r_numpy = STDDEV(self.df_default["close"].to_numpy(), timeperiod=5) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_statistic_correl(self): + """Statistic (CORREL): two Series from DataFrame, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = CORREL(c, h, timeperiod=5) + r_numpy = CORREL( + self.df_default["close"].to_numpy(), + self.df_default["high"].to_numpy(), + timeperiod=5, + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_volume_ad(self): + """Volume (AD): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = AD(h, l, c, v) + r_numpy = AD( + self.df_default["high"].to_numpy(), + self.df_default["low"].to_numpy(), + self.df_default["close"].to_numpy(), + self.df_default["volume"].to_numpy(), + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_volume_obv(self): + """Volume (OBV): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = OBV(c, v) + r_numpy = OBV( + self.df_default["close"].to_numpy(), + self.df_default["volume"].to_numpy(), + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_price_transform(self): + """Price transform (AVGPRICE): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = AVGPRICE(o, h, l, c) + r_numpy = AVGPRICE( + self.df_default["open"].to_numpy(), + self.df_default["high"].to_numpy(), + self.df_default["low"].to_numpy(), + self.df_default["close"].to_numpy(), + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + +# --------------------------------------------------------------------------- +# STOCH, STOCHRSI, ADX/DI/DM accuracy +# --------------------------------------------------------------------------- + + +class TestSTOCHAccuracy: + """STOCH SMA smoothing — basic correctness checks.""" + + def test_output_length(self): + k, d = STOCH(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(k) == len(OHLCV_PRICES) + assert len(d) == len(OHLCV_PRICES) + + def test_values_in_range(self): + k, d = STOCH(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + for v in _finite(k): + assert 0.0 <= v <= 100.0, f"slowk out of range: {v}" + for v in _finite(d): + assert 0.0 <= v <= 100.0, f"slowd out of range: {v}" + + def test_warmup_nans(self): + """First fastk_period + slowk_period - 2 bars should be NaN.""" + k, _ = STOCH(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, fastk_period=5, slowk_period=3) + warmup = 5 + 3 - 2 # = 6 + assert all(math.isnan(v) for v in k[:warmup]), "Expected NaN in warmup" + + def test_sma_smoothing(self): + """Verify SMA: slowk values are stable for constant high-close data.""" + # constant prices → fastk = 50% (close at midpoint) + n = 30 + h = np.ones(n) * 10.0 + l = np.zeros(n) + c = np.ones(n) * 5.0 # close at midpoint of range + k, d = STOCH(h, l, c, fastk_period=5, slowk_period=3, slowd_period=3) + finite_k = [v for v in k if not math.isnan(v)] + assert all(math.isclose(v, 50.0, abs_tol=1e-9) for v in finite_k), ( + f"Expected slowk=50 for close at midpoint; got {finite_k[:3]}" + ) + finite_d = [v for v in d if not math.isnan(v)] + assert all(math.isclose(v, 50.0, abs_tol=1e-9) for v in finite_d) + + +class TestSTOCHRSIAccuracy: + """STOCHRSI with SMA fastd.""" + + def test_output_length(self): + k, d = STOCHRSI(OHLCV_PRICES) + assert len(k) == len(OHLCV_PRICES) + assert len(d) == len(OHLCV_PRICES) + + def test_values_in_range(self): + prices = np.arange(1.0, 101.0) + k, d = STOCHRSI(prices, timeperiod=14, fastk_period=5, fastd_period=3) + for v in _finite(k): + assert 0.0 <= v <= 100.0 + for v in _finite(d): + assert 0.0 <= v <= 100.0 + + def test_fastd_is_sma_of_fastk(self): + """fastd[i] == mean(fastk[i-2:i+1]) for period=3.""" + prices = np.arange(1.0, 101.0) + np.sin(np.arange(100)) * 0.5 + k, d = STOCHRSI(prices, timeperiod=14, fastk_period=5, fastd_period=3) + # Find first valid fastd bar + first_d = next(i for i, v in enumerate(d) if not math.isnan(v)) + # Check SMA relationship + for i in range(first_d, len(d) - 1): + if not math.isnan(d[i]) and not any( + math.isnan(k[j]) for j in range(i - 2, i + 1) + ): + expected = (k[i] + k[i - 1] + k[i - 2]) / 3.0 + assert math.isclose(d[i], expected, rel_tol=1e-9), ( + f"SMA mismatch at {i}" + ) + break # one check is sufficient + + +class TestADXAccuracy: + """ADX/DX/+DI/-DI/PLUS_DM/MINUS_DM with TA-Lib sum-seeding.""" + + def test_adx_output_length(self): + assert len(ADX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE)) == len(OHLCV_PRICES) + + def test_adx_range(self): + r = ADX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + for v in _finite(r): + assert 0.0 <= v <= 100.0 + + def test_plus_di_range(self): + r = PLUS_DI(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + for v in _finite(r): + assert 0.0 <= v <= 100.0 + + def test_minus_di_range(self): + r = MINUS_DI(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + for v in _finite(r): + assert 0.0 <= v <= 100.0 + + def test_plus_dm_positive(self): + r = PLUS_DM(OHLCV_HIGH, OHLCV_LOW) + for v in _finite(r): + assert v >= 0.0 + + def test_minus_dm_positive(self): + r = MINUS_DM(OHLCV_HIGH, OHLCV_LOW) + for v in _finite(r): + assert v >= 0.0 + + def test_dx_output_length(self): + assert len(DX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE)) == len(OHLCV_PRICES) + + def test_adxr_output_length(self): + assert len(ADXR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE)) == len(OHLCV_PRICES) + + +# --------------------------------------------------------------------------- +# Extended Indicators (VWAP, Supertrend) +# --------------------------------------------------------------------------- + +from ferro_ta import SUPERTREND, VWAP + + +class TestVWAP: + """VWAP — Volume Weighted Average Price.""" + + H = np.array([11.0, 12.0, 13.0, 12.0, 11.0, 10.0, 9.0, 10.0, 11.0, 12.0]) + L = H - 1.0 + C = (H + L) / 2.0 + V = np.ones(10) * 1000.0 + + def test_output_length(self): + r = VWAP(self.H, self.L, self.C, self.V) + assert len(r) == len(self.H) + + def test_cumulative_no_nans(self): + """Cumulative VWAP (default) has no NaNs.""" + r = VWAP(self.H, self.L, self.C, self.V) + assert not np.any(np.isnan(r)) + + def test_cumulative_first_bar(self): + """First bar of cumulative VWAP equals its typical price.""" + r = VWAP(self.H, self.L, self.C, self.V) + tp0 = (self.H[0] + self.L[0] + self.C[0]) / 3.0 + assert math.isclose(r[0], tp0, rel_tol=1e-9) + + def test_cumulative_monotone_volume_contribution(self): + """Cumulative VWAP is bounded by min/max typical price.""" + r = VWAP(self.H, self.L, self.C, self.V) + tp = (self.H + self.L + self.C) / 3.0 + assert np.all(r >= tp.min() - 1e-9) + assert np.all(r <= tp.max() + 1e-9) + + def test_rolling_warmup_nans(self): + """Rolling VWAP has timeperiod-1 NaN values at the start.""" + r = VWAP(self.H, self.L, self.C, self.V, timeperiod=3) + assert np.isnan(r[0]) and np.isnan(r[1]) + assert not np.isnan(r[2]) + + def test_rolling_output_length(self): + r = VWAP(self.H, self.L, self.C, self.V, timeperiod=3) + assert len(r) == len(self.H) + + def test_constant_uniform_price(self): + """With uniform price and volume, VWAP == typical price.""" + n = 10 + h = np.full(n, 10.0) + l = np.full(n, 8.0) + c = np.full(n, 9.0) + v = np.full(n, 500.0) + tp = (10.0 + 8.0 + 9.0) / 3.0 + r = VWAP(h, l, c, v) + assert np.allclose(r, tp) + + +class TestSUPERTREND: + """Supertrend ATR-based trend indicator.""" + + N = 20 + H = np.array( + [10.0 + i * 0.5 if i < 10 else 15.0 - (i - 10) * 0.5 for i in range(N)] + ) + L = H - 1.0 + C = (H + L) / 2.0 + + def test_output_shape(self): + st, direction = SUPERTREND(self.H, self.L, self.C) + assert len(st) == len(self.H) + assert len(direction) == len(self.H) + + def test_warmup_nans(self): + """First timeperiod bars in supertrend should be NaN.""" + st, _ = SUPERTREND(self.H, self.L, self.C, timeperiod=7) + assert all(np.isnan(st[i]) for i in range(7)) + + def test_direction_values(self): + """Direction should only be -1, 0, or 1.""" + _, direction = SUPERTREND(self.H, self.L, self.C) + assert all(d in (-1, 0, 1) for d in direction) + + def test_direction_matches_price_vs_supertrend(self): + """When direction=1 (uptrend), close > supertrend.""" + st, direction = SUPERTREND(self.H, self.L, self.C) + for i in range(len(self.H)): + if direction[i] == 1: + assert self.C[i] > st[i] - 1e-9, ( + f"At {i}: close={self.C[i]}, st={st[i]}" + ) + elif direction[i] == -1: + assert self.C[i] < st[i] + 1e-9, ( + f"At {i}: close={self.C[i]}, st={st[i]}" + ) + + def test_supertrend_positive(self): + """Supertrend values should be positive.""" + st, _ = SUPERTREND(self.H, self.L, self.C) + for v in st[~np.isnan(st)]: + assert v > 0.0 + + def test_custom_multiplier(self): + """Higher multiplier widens bands → same trend can persist longer.""" + _, d1 = SUPERTREND(self.H, self.L, self.C, multiplier=1.0) + _, d2 = SUPERTREND(self.H, self.L, self.C, multiplier=5.0) + # Just check they both produce valid outputs + assert all(d in (-1, 0, 1) for d in d1) + assert all(d in (-1, 0, 1) for d in d2) + + def test_pandas_series_input(self): + """Accepts pandas Series and returns Series.""" + import pandas as pd + + idx = pd.date_range("2024-01-01", periods=self.N) + h_s = pd.Series(self.H, index=idx) + l_s = pd.Series(self.L, index=idx) + c_s = pd.Series(self.C, index=idx) + st, direction = SUPERTREND(h_s, l_s, c_s) + assert isinstance(st, pd.Series) + assert isinstance(direction, pd.Series) + assert list(st.index) == list(idx) + + +# --------------------------------------------------------------------------- +# Streaming / Incremental API +# --------------------------------------------------------------------------- + +from ferro_ta.data.streaming import ( + StreamingATR, + StreamingBBands, + StreamingEMA, + StreamingMACD, + StreamingRSI, + StreamingSMA, + StreamingStoch, + StreamingSupertrend, + StreamingVWAP, +) + + +class TestStreamingSMA: + def test_warmup_nans(self): + sma = StreamingSMA(3) + assert math.isnan(sma.update(1.0)) + assert math.isnan(sma.update(2.0)) + + def test_first_valid(self): + sma = StreamingSMA(3) + sma.update(1.0) + sma.update(2.0) + v = sma.update(3.0) + assert math.isclose(v, 2.0) + + def test_rolling(self): + sma = StreamingSMA(3) + [sma.update(x) for x in [1.0, 2.0, 3.0]] + v = sma.update(4.0) + assert math.isclose(v, 3.0) + + def test_matches_batch_sma(self): + import ferro_ta + + data = np.arange(1.0, 21.0) + batch = ferro_ta.SMA(data, timeperiod=5) + stream_sma = StreamingSMA(5) + for i, x in enumerate(data): + sv = stream_sma.update(x) + if not math.isnan(batch[i]): + assert math.isclose(sv, batch[i], rel_tol=1e-9) + + def test_reset(self): + sma = StreamingSMA(3) + [sma.update(x) for x in [1.0, 2.0, 3.0]] + sma.reset() + assert math.isnan(sma.update(1.0)) + + def test_period_1(self): + sma = StreamingSMA(1) + v = sma.update(42.0) + assert math.isclose(v, 42.0) + + +class TestStreamingEMA: + def test_warmup_nans(self): + ema = StreamingEMA(5) + for _ in range(4): + assert math.isnan(ema.update(1.0)) + + def test_first_valid(self): + ema = StreamingEMA(3) + ema.update(1.0) + ema.update(2.0) + v = ema.update(3.0) + assert math.isclose(v, 2.0) + + def test_matches_batch_ema(self): + """StreamingEMA (SMA-seeded) and batch EMA converge after enough bars.""" + import ferro_ta + + # Oscillating data helps convergence independent of seed + data = np.array([50.0 + 10.0 * math.sin(i * 0.3) for i in range(100)]) + period = 5 + batch = ferro_ta.EMA(data, timeperiod=period) + stream_ema = StreamingEMA(period) + converge_bar = period * 6 # allow seed to wash out fully + for i, x in enumerate(data): + sv = stream_ema.update(x) + if i >= converge_bar and not math.isnan(batch[i]): + # Allow 0.1% relative tolerance after convergence + assert math.isclose(sv, batch[i], rel_tol=1e-3), ( + f"i={i}: {sv} != {batch[i]}" + ) + + def test_reset(self): + ema = StreamingEMA(3) + [ema.update(x) for x in [1.0, 2.0, 3.0]] + ema.reset() + assert math.isnan(ema.update(1.0)) + + +class TestStreamingRSI: + def test_warmup(self): + rsi = StreamingRSI(14) + for _ in range(14): + assert math.isnan(rsi.update(50.0)) + + def test_constant_series_not_nan(self): + """Constant prices: RSI is defined (gain=0, loss=0 → special case).""" + rsi = StreamingRSI(5) + last = float("nan") + for _ in range(10): + last = rsi.update(100.0) + # With all gains=0 and losses=0, RSI returns 100 (avg_loss==0 branch) + # This is acceptable behavior for degenerate input. + assert not math.isnan(last) + + def test_always_rising_near_100(self): + rsi = StreamingRSI(5) + last = float("nan") + for i in range(20): + last = rsi.update(float(i)) + assert not math.isnan(last) and last > 90.0 + + def test_range(self): + rsi = StreamingRSI(5) + vals = [ + rsi.update(float(v)) + for v in [1.0, 2.0, 1.0, 3.0, 1.0, 4.0, 1.0, 5.0, 1.0, 6.0] + ] + for v in vals: + if not math.isnan(v): + assert 0.0 <= v <= 100.0 + + def test_reset(self): + rsi = StreamingRSI(3) + [rsi.update(x) for x in [1.0, 2.0, 3.0, 4.0]] + rsi.reset() + assert math.isnan(rsi.update(1.0)) + + +class TestStreamingATR: + def test_warmup(self): + atr = StreamingATR(3) + assert math.isnan(atr.update(11.0, 9.0, 10.0)) + assert math.isnan(atr.update(12.0, 10.0, 11.0)) + assert math.isnan(atr.update(13.0, 11.0, 12.0)) + + def test_positive(self): + atr = StreamingATR(3) + vals = [ + atr.update(h, l, c) + for h, l, c in [ + (11.0, 9.0, 10.0), + (12.0, 10.0, 11.0), + (13.0, 11.0, 12.0), + (14.0, 12.0, 13.0), + (15.0, 13.0, 14.0), + ] + ] + for v in vals: + if not math.isnan(v): + assert v > 0 + + def test_constant_range(self): + """With constant HL spread of 2 and no gaps, ATR converges to 2.""" + atr = StreamingATR(5) + h, l = 11.0, 9.0 + c = 10.0 + last = float("nan") + for _ in range(50): + last = atr.update(h, l, c) + assert math.isclose(last, 2.0, abs_tol=0.01) + + def test_reset(self): + atr = StreamingATR(3) + [ + atr.update(h, l, c) + for h, l, c in [(11.0, 9.0, 10.0), (12.0, 10.0, 11.0), (13.0, 11.0, 12.0)] + ] + atr.reset() + assert math.isnan(atr.update(11.0, 9.0, 10.0)) + + +class TestStreamingBBands: + def test_warmup(self): + bb = StreamingBBands(5) + for _ in range(4): + u, m, l = bb.update(10.0) + assert all(math.isnan(x) for x in [u, m, l]) + + def test_structure(self): + bb = StreamingBBands(3) + for _ in range(3): + u, m, l = bb.update(10.0) + assert u >= m >= l + + def test_constant_price(self): + """Constant price → std=0, all three bands equal to price.""" + bb = StreamingBBands(5) + u = m = l = float("nan") + for _ in range(20): + u, m, l = bb.update(42.0) + assert math.isclose(u, 42.0, abs_tol=1e-9) + assert math.isclose(m, 42.0, abs_tol=1e-9) + assert math.isclose(l, 42.0, abs_tol=1e-9) + + +class TestStreamingMACD: + def test_warmup(self): + macd = StreamingMACD() + for _ in range(25): + ml, s, h = macd.update(100.0) + # slowperiod=26, so at bar 25 (0-indexed) MACD line may not yet be valid + # (seeded after 26 bars). At this point both ml and s could still be NaN. + # Just verify they are floats. + assert isinstance(ml, float) and isinstance(s, float) and isinstance(h, float) + + def test_returns_three(self): + macd = StreamingMACD() + result = macd.update(100.0) + assert len(result) == 3 + + def test_histogram_equals_macd_minus_signal(self): + macd = StreamingMACD(fastperiod=3, slowperiod=6, signalperiod=2) + for _ in range(20): + ml, s, h = macd.update(float(_ + 1)) + if not math.isnan(ml) and not math.isnan(s): + assert math.isclose(h, ml - s, rel_tol=1e-9) + + def test_reset(self): + macd = StreamingMACD(fastperiod=3, slowperiod=6, signalperiod=2) + [macd.update(x) for x in np.arange(1.0, 20.0)] + macd.reset() + ml, s, h = macd.update(1.0) + assert math.isnan(ml) + + +class TestStreamingStoch: + def test_warmup(self): + stoch = StreamingStoch(5, 3, 3) + for _ in range(7): + k, d = stoch.update(10.0, 9.0, 9.5) + # k should be valid, d still might be NaN + assert isinstance(k, float) and isinstance(d, float) + + def test_range(self): + stoch = StreamingStoch(5, 3, 3) + for _ in range(20): + k, d = stoch.update(float(_ + 1), float(_), float(_ + 0.5)) + if not math.isnan(k): + assert 0 <= k <= 100 + + +class TestStreamingVWAP: + def test_cumulative(self): + vwap = StreamingVWAP() + v1 = vwap.update(11.0, 9.0, 10.0, 1000.0) + assert math.isclose(v1, (11.0 + 9.0 + 10.0) / 3.0) + + def test_always_valid(self): + vwap = StreamingVWAP() + for i in range(5): + v = vwap.update(10.0 + i, 9.0 + i, 9.5 + i, 1000.0) + assert not math.isnan(v) + + def test_reset(self): + vwap = StreamingVWAP() + vwap.update(11.0, 9.0, 10.0, 1000.0) + vwap.reset() + v = vwap.update(20.0, 18.0, 19.0, 500.0) + assert math.isclose(v, (20.0 + 18.0 + 19.0) / 3.0) + + +class TestStreamingSupertrend: + def test_warmup_nans(self): + st = StreamingSupertrend(3) + for _ in range(3): + line, d = st.update(10.0, 9.0, 9.5) + # First 3 bars: ATR warming up + # By bar 4 it should be valid + line, d = st.update(11.0, 10.0, 10.5) + assert not math.isnan(line) + assert d in (-1, 0, 1) + + def test_direction_values(self): + st = StreamingSupertrend(3) + for i in range(20): + line, d = st.update(10.0 + i * 0.5, 9.0 + i * 0.5, 9.5 + i * 0.5) + assert d in (-1, 1) + + def test_reset(self): + st = StreamingSupertrend(3) + [st.update(10.0 + i, 9.0 + i, 9.5 + i) for i in range(10)] + st.reset() + line, d = st.update(10.0, 9.0, 9.5) + assert d == 0 # warmup + + +# --------------------------------------------------------------------------- +# Additional Extended Indicators (ICHIMOKU, DONCHIAN, PIVOT_POINTS) +# --------------------------------------------------------------------------- + +from ferro_ta import DONCHIAN, ICHIMOKU, PIVOT_POINTS + + +class TestICHIMOKU: + N = 80 + H = np.arange(10.0, 10.0 + N) + np.sin(np.arange(N)) * 0.5 + L = H - 1.5 + C = (H + L) / 2.0 + + def test_output_shapes(self): + t, k, sa, sb, ch = ICHIMOKU(self.H, self.L, self.C) + for arr in (t, k, sa, sb, ch): + assert len(arr) == self.N + + def test_tenkan_warmup(self): + t, *_ = ICHIMOKU(self.H, self.L, self.C, tenkan_period=9) + assert all(np.isnan(t[:8])) + assert not np.isnan(t[8]) + + def test_kijun_warmup(self): + _, k, *_ = ICHIMOKU(self.H, self.L, self.C, kijun_period=26) + assert all(np.isnan(k[:25])) + assert not np.isnan(k[25]) + + def test_tenkan_is_midpoint(self): + t, *_ = ICHIMOKU(self.H, self.L, self.C, tenkan_period=5) + for i in range(4, self.N): + expected = (self.H[i - 4 : i + 1].max() + self.L[i - 4 : i + 1].min()) / 2.0 + assert math.isclose(t[i], expected, rel_tol=1e-9) + + def test_chikou_is_shifted_close(self): + *_, ch = ICHIMOKU(self.H, self.L, self.C, displacement=26) + # chikou[26:] == close[0 : N-26] + for i in range(26, self.N): + assert math.isclose(ch[i], self.C[i - 26], rel_tol=1e-9) + + def test_pandas_output(self): + import pandas as pd + + idx = pd.date_range("2024-01-01", periods=self.N) + h_s = pd.Series(self.H, index=idx) + l_s = pd.Series(self.L, index=idx) + c_s = pd.Series(self.C, index=idx) + t, k, sa, sb, ch = ICHIMOKU(h_s, l_s, c_s) + for s in (t, k, sa, sb, ch): + assert isinstance(s, pd.Series) + + +class TestDONCHIAN: + N = 30 + H = np.arange(1.0, N + 1.0) + L = np.zeros(N) + + def test_output_shape(self): + u, m, lo = DONCHIAN(self.H, self.L, 10) + for arr in (u, m, lo): + assert len(arr) == self.N + + def test_warmup_nans(self): + u, m, lo = DONCHIAN(self.H, self.L, 10) + for arr in (u, m, lo): + assert all(np.isnan(arr[:9])) + assert not np.isnan(arr[9]) + + def test_upper_is_max_high(self): + u, _, _ = DONCHIAN(self.H, self.L, 5) + for i in range(4, self.N): + assert math.isclose(u[i], self.H[i - 4 : i + 1].max(), rel_tol=1e-9) + + def test_lower_is_min_low(self): + _, _, lo = DONCHIAN(self.H, self.L, 5) + for i in range(4, self.N): + assert math.isclose(lo[i], self.L[i - 4 : i + 1].min(), rel_tol=1e-9) + + def test_middle_is_avg(self): + u, m, lo = DONCHIAN(self.H, self.L, 5) + for i in range(4, self.N): + if not np.isnan(u[i]): + assert math.isclose(m[i], (u[i] + lo[i]) / 2.0, rel_tol=1e-9) + + def test_monotone_upper(self): + """With monotone-increasing H, upper band is non-decreasing.""" + u, _, _ = DONCHIAN(self.H, self.L, 5) + valid = u[~np.isnan(u)] + assert all(valid[i] <= valid[i + 1] for i in range(len(valid) - 1)) + + +class TestPIVOT_POINTS: + N = 10 + H = np.array([12.0, 13.0, 14.0, 13.0, 12.0, 11.0, 12.0, 13.0, 14.0, 15.0]) + L = H - 2.0 + C = H - 1.0 + + def test_output_shape(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C) + for arr in (p, r1, s1, r2, s2): + assert len(arr) == self.N + + def test_first_bar_nan(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C) + for arr in (p, r1, s1, r2, s2): + assert np.isnan(arr[0]) + + def test_classic_pivot_formula(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C, method="classic") + for i in range(1, self.N): + ph, pl, pc = self.H[i - 1], self.L[i - 1], self.C[i - 1] + expected_p = (ph + pl + pc) / 3.0 + assert math.isclose(p[i], expected_p, rel_tol=1e-9) + assert math.isclose(r1[i], 2 * expected_p - pl, rel_tol=1e-9) + assert math.isclose(s1[i], 2 * expected_p - ph, rel_tol=1e-9) + + def test_fibonacci_method(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C, method="fibonacci") + for i in range(1, self.N): + ph, pl, pc = self.H[i - 1], self.L[i - 1], self.C[i - 1] + pp = (ph + pl + pc) / 3.0 + hl = ph - pl + assert math.isclose(r1[i], pp + 0.382 * hl, rel_tol=1e-9) + assert math.isclose(s1[i], pp - 0.382 * hl, rel_tol=1e-9) + + def test_camarilla_method(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C, method="camarilla") + for i in range(1, self.N): + ph, pl, pc = self.H[i - 1], self.L[i - 1], self.C[i - 1] + hl = ph - pl + assert math.isclose(r1[i], pc + 1.1 * hl / 12.0, rel_tol=1e-9) + + def test_invalid_method_raises(self): + import pytest + + with pytest.raises(ValueError, match="Unknown pivot method"): + PIVOT_POINTS(self.H, self.L, self.C, method="unknown") + + def test_r1_gt_pivot_gt_s1(self): + p, r1, s1, _, _ = PIVOT_POINTS(self.H, self.L, self.C, method="classic") + for i in range(1, self.N): + if not np.isnan(p[i]): + assert r1[i] > p[i] > s1[i] + + +# --------------------------------------------------------------------------- +# New Extended Indicators (KELTNER_CHANNELS, HULL_MA, +# CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) +# --------------------------------------------------------------------------- + +from ferro_ta import ( + CHANDELIER_EXIT, + CHOPPINESS_INDEX, + HULL_MA, + KELTNER_CHANNELS, + VWMA, +) + + +class TestKELTNER_CHANNELS: + N = 30 + C = np.cumsum(np.ones(N)) + 40.0 + H = C + 0.5 + L = C - 0.5 + + def test_output_shapes(self): + u, m, lo = KELTNER_CHANNELS(self.H, self.L, self.C, timeperiod=5, atr_period=3) + assert len(u) == len(m) == len(lo) == self.N + + def test_upper_gt_middle_gt_lower(self): + u, m, lo = KELTNER_CHANNELS(self.H, self.L, self.C, timeperiod=5, atr_period=3) + valid = ~np.isnan(u) + assert np.all(u[valid] > m[valid]) + assert np.all(m[valid] > lo[valid]) + + def test_middle_is_ema(self): + from ferro_ta import EMA + + u, m, lo = KELTNER_CHANNELS(self.H, self.L, self.C, timeperiod=5, atr_period=3) + ema = EMA(self.C, timeperiod=5) + valid = ~np.isnan(m) & ~np.isnan(ema) + assert np.allclose(m[valid], ema[valid], rtol=1e-9) + + +class TestHULL_MA: + N = 30 + C = np.cumsum(np.ones(N)) + 40.0 + + def test_output_length(self): + hull = HULL_MA(self.C, timeperiod=4) + assert len(hull) == self.N + + def test_leading_nans(self): + hull = HULL_MA(self.C, timeperiod=4) + assert int(np.sum(np.isnan(hull))) >= 1 + + def test_finite_after_warmup(self): + hull = HULL_MA(self.C, timeperiod=4) + assert np.all(np.isfinite(hull[~np.isnan(hull)])) + + def test_linear_series_tracks_input(self): + """For a perfectly linear series, HMA should be close to close.""" + c = np.arange(1.0, 31.0) + hull = HULL_MA(c, timeperiod=4) + valid = ~np.isnan(hull) + # Should be within 5% of actual price + assert np.all(np.abs(hull[valid] - c[valid]) < c[valid] * 0.05) + + +class TestCHANDELIER_EXIT: + N = 30 + C = np.cumsum(np.ones(N)) + 40.0 + H = C + 0.5 + L = C - 0.5 + + def test_output_shapes(self): + le, se = CHANDELIER_EXIT(self.H, self.L, self.C, timeperiod=5, multiplier=2.0) + assert len(le) == len(se) == self.N + + def test_long_lt_highest_high(self): + le, _ = CHANDELIER_EXIT(self.H, self.L, self.C, timeperiod=5, multiplier=2.0) + valid = ~np.isnan(le) + # long exit must be below the local highest high + from ferro_ta import MAX + + hh = MAX(self.H, timeperiod=5) + assert np.all(le[valid] <= hh[valid]) + + def test_short_gt_lowest_low(self): + _, se = CHANDELIER_EXIT(self.H, self.L, self.C, timeperiod=5, multiplier=2.0) + valid = ~np.isnan(se) + from ferro_ta import MIN + + ll = MIN(self.L, timeperiod=5) + assert np.all(se[valid] >= ll[valid]) + + +class TestVWMA: + N = 20 + C = np.full(N, 50.0) + V = np.full(N, 1_000.0) + + def test_output_length(self): + v = VWMA(self.C, self.V, timeperiod=5) + assert len(v) == self.N + + def test_leading_nans(self): + v = VWMA(self.C, self.V, timeperiod=5) + assert int(np.sum(np.isnan(v))) == 4 + + def test_constant_price_equals_price(self): + """When price is constant, VWMA == price regardless of volume.""" + v = VWMA(self.C, self.V, timeperiod=5) + valid = ~np.isnan(v) + assert np.allclose(v[valid], 50.0, rtol=1e-9) + + def test_weighted_by_volume(self): + """Higher volume at a price bar should pull VWMA toward that price.""" + close = np.array([10.0] * 5 + [20.0]) + vol = np.array([1.0] * 5 + [100.0]) + v = VWMA(close, vol, timeperiod=6) + assert v[-1] > 19.0 # strongly weighted toward 20.0 + + +class TestCHOPPINESS_INDEX: + N = 30 + C = np.cumsum(np.ones(N)) + 40.0 + H = C + 0.5 + L = C - 0.5 + + def test_output_length(self): + ci = CHOPPINESS_INDEX(self.H, self.L, self.C, timeperiod=5) + assert len(ci) == self.N + + def test_leading_nans(self): + ci = CHOPPINESS_INDEX(self.H, self.L, self.C, timeperiod=5) + assert np.sum(~np.isnan(ci)) <= self.N - 5 + + def test_range_0_to_100(self): + """Choppiness Index should be in (0, 100].""" + ci = CHOPPINESS_INDEX(self.H, self.L, self.C, timeperiod=5) + valid = ci[~np.isnan(ci)] + if len(valid) > 0: + assert np.all(valid >= 0.0) + assert np.all(valid <= 100.0) + + def test_trending_market_lower_than_choppy(self): + """A strong trend should have lower CI than a sideways market.""" + # Trending: monotone rise + trend_c = np.arange(1.0, 31.0) + trend_h = trend_c + 0.1 + trend_l = trend_c - 0.1 + ci_trend = CHOPPINESS_INDEX(trend_h, trend_l, trend_c, timeperiod=14) + + # Choppy: alternating + chop_c = np.array([50.0 + ((-1) ** i) * 1.0 for i in range(30)]) + chop_h = chop_c + 0.1 + chop_l = chop_c - 0.1 + ci_chop = CHOPPINESS_INDEX(chop_h, chop_l, chop_c, timeperiod=14) + + valid_t = ci_trend[~np.isnan(ci_trend)] + valid_c = ci_chop[~np.isnan(ci_chop)] + if len(valid_t) > 0 and len(valid_c) > 0: + assert np.mean(valid_t) < np.mean(valid_c) diff --git a/ferro-ta-main/tests/unit/test_infrastructure.py b/ferro-ta-main/tests/unit/test_infrastructure.py new file mode 100644 index 0000000..c7bf687 --- /dev/null +++ b/ferro-ta-main/tests/unit/test_infrastructure.py @@ -0,0 +1,1060 @@ +"""Tests for exceptions, backtest, registry, release playbook, GPU backend, WASM.""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ferro_ta + +# --------------------------------------------------------------------------- +# Exception model & validation +# --------------------------------------------------------------------------- +from ferro_ta.core.exceptions import ( + FerroTAError, + FerroTAInputError, + FerroTAValueError, + check_equal_length, + check_finite, + check_timeperiod, +) + + +class TestExceptionHierarchy: + """FerroTAError hierarchy and isinstance relationships.""" + + def test_ferro_ta_error_is_exception(self): + assert issubclass(FerroTAError, Exception) + + def test_value_error_is_base_and_value_error(self): + assert issubclass(FerroTAValueError, FerroTAError) + assert issubclass(FerroTAValueError, ValueError) + + def test_input_error_is_base_and_value_error(self): + assert issubclass(FerroTAInputError, FerroTAError) + assert issubclass(FerroTAInputError, ValueError) + + def test_exported_from_ferro_ta(self): + assert ferro_ta.FerroTAError is FerroTAError + assert ferro_ta.FerroTAValueError is FerroTAValueError + assert ferro_ta.FerroTAInputError is FerroTAInputError + + +class TestCheckTimeperiod: + """check_timeperiod raises FerroTAValueError with clear message.""" + + def test_valid_timeperiod_does_not_raise(self): + check_timeperiod(1) + check_timeperiod(14) + check_timeperiod(100) + + def test_zero_raises_ferro_ta_value_error(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1, got 0"): + check_timeperiod(0) + + def test_negative_raises_ferro_ta_value_error(self): + with pytest.raises(FerroTAValueError) as exc_info: + check_timeperiod(-5, name="timeperiod") + assert "timeperiod" in str(exc_info.value) + assert "-5" in str(exc_info.value) + + def test_custom_name_in_message(self): + with pytest.raises(FerroTAValueError, match="fastperiod"): + check_timeperiod(0, name="fastperiod") + + def test_custom_minimum(self): + with pytest.raises(FerroTAValueError, match=">= 2"): + check_timeperiod(1, minimum=2) + + +class TestCheckEqualLength: + """check_equal_length raises FerroTAInputError for mismatched arrays.""" + + def test_equal_lengths_pass(self): + a = np.array([1.0, 2.0, 3.0]) + b = np.array([4.0, 5.0, 6.0]) + check_equal_length(open=a, close=b) # no exception + + def test_mismatched_lengths_raise(self): + a = np.array([1.0, 2.0, 3.0]) + b = np.array([4.0, 5.0]) + with pytest.raises(FerroTAInputError) as exc_info: + check_equal_length(open=a, close=b) + # message must mention the lengths + msg = str(exc_info.value) + assert "3" in msg + assert "2" in msg + + def test_three_arrays_all_different(self): + with pytest.raises(FerroTAInputError): + check_equal_length( + open=np.array([1.0]), + high=np.array([1.0, 2.0]), + close=np.array([1.0, 2.0, 3.0]), + ) + + +class TestCheckFinite: + """check_finite raises FerroTAInputError for NaN/Inf.""" + + def test_all_finite_passes(self): + check_finite(np.array([1.0, 2.0, 3.0])) + + def test_nan_raises(self): + with pytest.raises(FerroTAInputError, match="NaN or Inf"): + check_finite(np.array([1.0, float("nan"), 3.0])) + + def test_inf_raises(self): + with pytest.raises(FerroTAInputError, match="NaN or Inf"): + check_finite(np.array([1.0, float("inf"), 3.0])) + + def test_name_in_message(self): + with pytest.raises(FerroTAInputError, match="myarray"): + check_finite(np.array([float("nan")]), name="myarray") + + +# --------------------------------------------------------------------------- +# Backtesting utilities +# --------------------------------------------------------------------------- + +from ferro_ta.analysis.backtest import ( + BacktestResult, + backtest, + macd_crossover_strategy, + rsi_strategy, + sma_crossover_strategy, +) + + +def _make_close(n: int = 50, seed: int = 42) -> np.ndarray: + rng = np.random.default_rng(seed) + returns = rng.normal(0.001, 0.01, n) + return np.cumprod(1 + returns) * 100.0 + + +class TestRsiStrategy: + """rsi_strategy returns correct signal arrays.""" + + def test_output_shape(self): + close = _make_close(50) + signals = rsi_strategy(close, timeperiod=5) + assert signals.shape == close.shape + + def test_only_valid_signal_values(self): + close = _make_close(50) + signals = rsi_strategy(close, timeperiod=5) + finite = signals[np.isfinite(signals)] + assert set(finite).issubset({-1.0, 0.0, 1.0}) + + def test_nan_during_warmup(self): + close = _make_close(20) + signals = rsi_strategy(close, timeperiod=5) + # First 5 values should be NaN (RSI warm-up) + assert np.all(np.isnan(signals[:5])) + + def test_invalid_timeperiod(self): + with pytest.raises(FerroTAValueError): + rsi_strategy(_make_close(10), timeperiod=0) + + +class TestSmaCrossoverStrategy: + """sma_crossover_strategy returns signals when fast < slow.""" + + def test_output_shape(self): + close = _make_close(60) + signals = sma_crossover_strategy(close, fast=5, slow=20) + assert signals.shape == close.shape + + def test_only_valid_signal_values(self): + close = _make_close(60) + signals = sma_crossover_strategy(close, fast=5, slow=20) + finite = signals[np.isfinite(signals)] + assert set(finite).issubset({-1.0, 1.0}) + + def test_fast_must_be_less_than_slow(self): + with pytest.raises(FerroTAValueError): + sma_crossover_strategy(_make_close(60), fast=20, slow=10) + + +class TestMacdCrossoverStrategy: + """macd_crossover_strategy returns signals from MACD line vs signal line.""" + + def test_output_shape(self): + close = _make_close(100) + signals = macd_crossover_strategy( + close, fastperiod=12, slowperiod=26, signalperiod=9 + ) + assert signals.shape == close.shape + + def test_only_valid_signal_values(self): + close = _make_close(100) + signals = macd_crossover_strategy( + close, fastperiod=12, slowperiod=26, signalperiod=9 + ) + finite = signals[np.isfinite(signals)] + assert set(finite).issubset({-1.0, 1.0}) + + def test_fastperiod_must_be_less_than_slowperiod(self): + with pytest.raises(FerroTAValueError): + macd_crossover_strategy(_make_close(60), fastperiod=26, slowperiod=12) + + +class TestBacktest: + """backtest() produces correct BacktestResult.""" + + def test_rsi_strategy_runs(self): + close = _make_close(100) + result = backtest(close, strategy="rsi_30_70", timeperiod=5) + assert isinstance(result, BacktestResult) + + def test_output_lengths_match_input(self): + close = _make_close(80) + result = backtest(close, strategy="rsi_30_70", timeperiod=5) + n = len(close) + assert len(result.signals) == n + assert len(result.positions) == n + assert len(result.equity) == n + + def test_equity_starts_near_one(self): + close = _make_close(50) + result = backtest(close, strategy="rsi_30_70", timeperiod=5) + assert abs(result.equity[0] - 1.0) < 0.01 + + def test_sma_crossover_strategy_runs(self): + close = _make_close(80) + result = backtest(close, strategy="sma_crossover", fast=5, slow=20) + assert isinstance(result, BacktestResult) + assert result.n_trades >= 0 + + def test_custom_callable_strategy(self): + def my_strategy(close, **_): + signals = np.zeros(len(close)) + signals[len(close) // 2 :] = 1.0 + return signals + + close = _make_close(40) + result = backtest(close, strategy=my_strategy) + assert isinstance(result, BacktestResult) + assert len(result.signals) == len(close) + + def test_unknown_strategy_raises(self): + with pytest.raises(FerroTAValueError, match="Unknown strategy"): + backtest(_make_close(30), strategy="nonexistent") + + def test_too_short_input_raises(self): + with pytest.raises(FerroTAInputError): + backtest(np.array([1.0])) + + def test_non_1d_input_raises(self): + with pytest.raises(FerroTAInputError): + backtest(np.array([[1.0, 2.0], [3.0, 4.0]])) + + def test_n_trades_is_integer(self): + close = _make_close(60) + result = backtest(close, strategy="sma_crossover", fast=5, slow=15) + assert isinstance(result.n_trades, int) + assert result.n_trades >= 0 + + def test_macd_crossover_strategy_runs(self): + close = _make_close(100) + result = backtest( + close, + strategy="macd_crossover", + fastperiod=12, + slowperiod=26, + signalperiod=9, + ) + assert isinstance(result, BacktestResult) + assert len(result.equity) == len(close) + + def test_commission_reduces_equity(self): + close = _make_close(80) + result_no_comm = backtest(close, strategy="sma_crossover", fast=5, slow=20) + result_with_comm = backtest( + close, + strategy="sma_crossover", + fast=5, + slow=20, + commission_per_trade=0.01, + ) + assert result_with_comm.final_equity <= result_no_comm.final_equity + assert result_with_comm.final_equity < result_no_comm.final_equity or ( + result_no_comm.n_trades == 0 + ) + + def test_slippage_reduces_equity(self): + close = _make_close(80) + result_no_slip = backtest(close, strategy="sma_crossover", fast=5, slow=20) + result_with_slip = backtest( + close, + strategy="sma_crossover", + fast=5, + slow=20, + slippage_bps=10.0, + ) + assert result_with_slip.final_equity <= result_no_slip.final_equity + assert result_with_slip.final_equity < result_no_slip.final_equity or ( + result_no_slip.n_trades == 0 + ) + + 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 + + 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 + ) + np.testing.assert_allclose(result.positions, expected_positions) + # 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 + + +# --------------------------------------------------------------------------- +# Plugin / Registry +# --------------------------------------------------------------------------- + +from ferro_ta.core.registry import ( + FerroTARegistryError, + get, + list_indicators, + register, + run, + unregister, +) + + +class TestRegistry: + """Registry: register, get, run, unregister, list_indicators.""" + + def test_builtins_registered(self): + names = list_indicators() + assert "SMA" in names + assert "RSI" in names + assert "EMA" in names + assert "ATR" in names + + def test_run_builtin_sma(self): + close = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = run("SMA", close, timeperiod=3) + # SMA(3) of [1,2,3,4,5]: valid at indices 2,3,4 + assert result.shape == (5,) + assert np.isnan(result[0]) + assert abs(float(result[2]) - 2.0) < 1e-8 + + def test_run_builtin_rsi(self): + close = 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, + ] + ) + result = run("RSI", close, timeperiod=14) + assert result.shape == (15,) + + def test_get_returns_callable(self): + fn = get("EMA") + assert callable(fn) + + def test_register_custom_indicator(self): + def DOUBLE_SMA(close, timeperiod=5): + return close * 2.0 + + register("DOUBLE_SMA", DOUBLE_SMA) + try: + close = np.array([1.0, 2.0, 3.0]) + result = run("DOUBLE_SMA", close, timeperiod=2) + np.testing.assert_array_equal(result, np.array([2.0, 4.0, 6.0])) + finally: + unregister("DOUBLE_SMA") + + def test_unregister_removes_indicator(self): + def TEMP_IND(close): + return close + + register("TEMP_IND", TEMP_IND) + assert "TEMP_IND" in list_indicators() + unregister("TEMP_IND") + assert "TEMP_IND" not in list_indicators() + + def test_unknown_indicator_raises(self): + with pytest.raises(FerroTARegistryError): + get("UNKNOWN_INDICATOR_XYZ") + + def test_run_unknown_indicator_raises(self): + with pytest.raises(FerroTARegistryError): + run("NO_SUCH_IND", np.array([1.0, 2.0])) + + def test_unregister_unknown_raises(self): + with pytest.raises(FerroTARegistryError): + unregister("NEVER_REGISTERED") + + def test_register_non_callable_raises(self): + with pytest.raises(TypeError): + register("BAD", 42) # type: ignore[arg-type] + + def test_list_indicators_is_sorted(self): + names = list_indicators() + assert names == sorted(names) + + def test_all_builtins_are_callable(self): + for name in list_indicators(): + fn = get(name) + assert callable(fn), f"{name} is not callable" + + +# --------------------------------------------------------------------------- +# New Extended Indicators (KELTNER_CHANNELS, HULL_MA, +# CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) +# --------------------------------------------------------------------------- + +from ferro_ta import ( + CHANDELIER_EXIT, + CHOPPINESS_INDEX, + HULL_MA, + KELTNER_CHANNELS, + VWMA, +) + +_N = 30 +_C = np.cumsum(np.ones(_N)) + 40.0 +_H = _C + 0.5 +_L = _C - 0.5 +_V = np.full(_N, 1_000_000.0) + + +class TestKeltnerChannels: + def test_output_shapes(self): + u, m, lo = KELTNER_CHANNELS(_H, _L, _C, timeperiod=5, atr_period=3) + assert len(u) == len(m) == len(lo) == _N + + def test_upper_gt_middle_gt_lower(self): + u, m, lo = KELTNER_CHANNELS(_H, _L, _C, timeperiod=5, atr_period=3) + valid = ~np.isnan(u) + assert np.all(u[valid] > m[valid]) + assert np.all(m[valid] > lo[valid]) + + +class TestHullMA: + def test_output_length(self): + hull = HULL_MA(_C, timeperiod=4) + assert len(hull) == _N + + def test_leading_nans(self): + hull = HULL_MA(_C, timeperiod=4) + assert int(np.sum(np.isnan(hull))) >= 1 + + def test_finite_after_warmup(self): + hull = HULL_MA(_C, timeperiod=4) + assert np.all(np.isfinite(hull[~np.isnan(hull)])) + + +class TestChandelierExit: + def test_output_shapes(self): + le, se = CHANDELIER_EXIT(_H, _L, _C, timeperiod=5, multiplier=2.0) + assert len(le) == len(se) == _N + + def test_long_lt_high_short_gt_low(self): + le, se = CHANDELIER_EXIT(_H, _L, _C, timeperiod=5, multiplier=2.0) + # Both outputs should have valid values after warmup + valid_le = ~np.isnan(le) + valid_se = ~np.isnan(se) + assert valid_le.any() + assert valid_se.any() + # Long exit must be finite and positive + assert np.all(np.isfinite(le[valid_le])) + assert np.all(le[valid_le] > 0.0) + # Short exit must be finite and positive + assert np.all(np.isfinite(se[valid_se])) + assert np.all(se[valid_se] > 0.0) + + +class TestVWMA: + def test_output_length(self): + v = VWMA(_C, _V, timeperiod=5) + assert len(v) == _N + + def test_leading_nans(self): + v = VWMA(_C, _V, timeperiod=5) + assert int(np.sum(np.isnan(v))) == 4 + + def test_uniform_volume_equals_sma(self): + """With uniform volume, VWMA equals SMA.""" + from ferro_ta import SMA + + c = np.arange(1.0, 21.0) + v = np.ones(20) + vwma = VWMA(c, v, timeperiod=5) + sma = SMA(c, timeperiod=5) + valid = ~np.isnan(vwma) & ~np.isnan(sma) + assert np.allclose(vwma[valid], sma[valid], rtol=1e-9) + + +class TestChoppinessIndex: + def test_output_length(self): + ci = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=5) + assert len(ci) == _N + + def test_range_0_to_100(self): + ci = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=5) + valid = ci[~np.isnan(ci)] + if len(valid) > 0: + assert np.all(valid >= 0.0) + assert np.all(valid <= 100.0) + + +# --------------------------------------------------------------------------- +# Batch execution API +# --------------------------------------------------------------------------- + +from ferro_ta import EMA, RSI, SMA +from ferro_ta.data.batch import ( + batch_apply, + batch_atr, + batch_ema, + batch_rsi, + batch_sma, +) + + +class TestBatchSMA: + C2D = np.random.default_rng(7).random((50, 3)) + 50.0 + C1D = C2D[:, 0] + + def test_output_shape_2d(self): + result = batch_sma(self.C2D, timeperiod=10) + assert result.shape == (50, 3) + + def test_output_shape_1d_unchanged(self): + """1-D input should return 1-D (backward compatible).""" + result = batch_sma(self.C1D, timeperiod=10) + assert result.ndim == 1 + assert len(result) == 50 + + def test_column_matches_single_series(self): + """Each column of batch_sma must match single-series SMA.""" + result = batch_sma(self.C2D, timeperiod=10) + for j in range(3): + expected = SMA(self.C2D[:, j], timeperiod=10) + assert np.allclose(result[:, j], expected, equal_nan=True) + + +class TestBatchEMA: + C2D = np.random.default_rng(8).random((50, 4)) + 40.0 + + def test_output_shape(self): + result = batch_ema(self.C2D, timeperiod=5) + assert result.shape == (50, 4) + + def test_column_matches_single_series(self): + result = batch_ema(self.C2D, timeperiod=5) + for j in range(4): + expected = EMA(self.C2D[:, j], timeperiod=5) + assert np.allclose(result[:, j], expected, equal_nan=True) + + +class TestBatchRSI: + C2D = np.random.default_rng(9).random((50, 2)) + 45.0 + + def test_output_shape(self): + result = batch_rsi(self.C2D, timeperiod=14) + assert result.shape == (50, 2) + + def test_values_in_range(self): + result = batch_rsi(self.C2D, timeperiod=14) + valid = result[~np.isnan(result)] + if len(valid) > 0: + assert valid.min() >= 0.0 + assert valid.max() <= 100.0 + + def test_column_matches_single_series(self): + result = batch_rsi(self.C2D, timeperiod=14) + for j in range(2): + expected = RSI(self.C2D[:, j], timeperiod=14) + assert np.allclose(result[:, j], expected, equal_nan=True) + + +class TestBatchApply: + C2D = np.random.default_rng(11).random((40, 3)) + 50.0 + + def test_custom_fn(self): + """batch_apply should delegate to any single-series function.""" + from ferro_ta import BBANDS + + def mid(c, **kw): + return BBANDS(c, **kw)[1] + + result = batch_apply(self.C2D, mid, timeperiod=5) + assert result.shape == (40, 3) + + def test_3d_raises(self): + with pytest.raises(ValueError, match="1-D or 2-D"): + batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3) + + def test_sma_fastpath_matches_batch_sma(self): + from ferro_ta.data.batch import batch_sma + + fast = batch_apply(self.C2D, SMA, timeperiod=10) + direct = batch_sma(self.C2D, timeperiod=10) + assert np.allclose(fast, direct, equal_nan=True) + + +class TestBatchShapeValidation: + def test_batch_atr_shape_mismatch_raises(self): + high = np.ones((5, 2), dtype=np.float64) + low = np.ones((4, 2), dtype=np.float64) + close = np.ones((5, 2), dtype=np.float64) + with pytest.raises(ValueError, match="shape"): + batch_atr(high, low, close, timeperiod=3) + + +# --------------------------------------------------------------------------- +# Release playbook and version consistency +# --------------------------------------------------------------------------- + +import os +import re +import runpy +import subprocess + +try: + import tomllib # Python 3.11+ +except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] # fallback for Python < 3.11 + except ImportError: + tomllib = None # type: ignore[assignment] + + +def _read_cargo_version() -> str: + """Extract version from root Cargo.toml.""" + if tomllib is None: + raise ImportError("tomllib/tomli not available") + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + cargo_toml = os.path.join(root, "Cargo.toml") + with open(cargo_toml, "rb") as f: + data = tomllib.load(f) + return data["package"]["version"] + + +def _read_pyproject_version() -> str: + """Extract version from pyproject.toml.""" + if tomllib is None: + raise ImportError("tomllib/tomli not available") + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + pyproject_toml = os.path.join(root, "pyproject.toml") + with open(pyproject_toml, "rb") as f: + data = tomllib.load(f) + return data["project"]["version"] + + +def _read_conda_version() -> str: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + conda_meta = os.path.join(root, "conda", "meta.yaml") + text = open(conda_meta).read() + match = re.search(r'{% set version = "([^"]+)" %}', text) + if not match: + raise ValueError("Could not find conda version") + return match.group(1) + + +def _read_docs_release() -> str: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + conf_py = os.path.join(root, "docs", "conf.py") + old_env = os.environ.pop("FERRO_TA_VERSION", None) + try: + data = runpy.run_path(conf_py) + return data["release"] + finally: + if old_env is not None: + os.environ["FERRO_TA_VERSION"] = old_env + + +def _run_bump_version_check() -> subprocess.CompletedProcess[str]: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + return subprocess.run( + ["python3", "scripts/bump_version.py", "--check"], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + + +class TestVersionConsistency: + """Public version strings should stay aligned with the package version.""" + + def test_versions_match(self): + try: + cargo_ver = _read_cargo_version() + pyproject_ver = _read_pyproject_version() + except Exception: + pytest.skip("tomllib unavailable or files not found") + assert cargo_ver == pyproject_ver, ( + f"Version mismatch: Cargo.toml={cargo_ver!r}, " + f"pyproject.toml={pyproject_ver!r}" + ) + + def test_package_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + assert ferro_ta.__version__ == cargo_ver + + def test_conda_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + conda_ver = _read_conda_version() + assert conda_ver == cargo_ver + + def test_docs_release_matches_project_version(self): + cargo_ver = _read_cargo_version() + docs_release = _read_docs_release() + assert docs_release == cargo_ver + + def test_docs_changelog_mentions_current_version(self): + cargo_ver = _read_cargo_version() + root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + changelog_rst = os.path.join(root, "docs", "changelog.rst") + text = open(changelog_rst).read() + assert cargo_ver in text + + def test_api_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + try: + from api.main import app + except Exception: + pytest.skip("api/main.py not importable") + assert app.version == cargo_ver + + def test_bump_version_check_passes(self): + result = _run_bump_version_check() + assert result.returncode == 0, result.stdout + result.stderr + + def test_release_md_exists(self): + """RELEASE.md must exist in the repository root.""" + root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + release_md = os.path.join(root, "RELEASE.md") + assert os.path.isfile(release_md), "RELEASE.md not found" + + def test_release_md_has_key_sections(self): + """RELEASE.md must mention tagging and PyPI.""" + root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + release_md = os.path.join(root, "RELEASE.md") + if not os.path.isfile(release_md): + pytest.skip("RELEASE.md not found") + text = open(release_md).read() + assert "git tag" in text or "tag" in text.lower() + assert "pypi" in text.lower() or "PyPI" in text + + +# --------------------------------------------------------------------------- +# GPU backend (PyTorch, CPU fallback always available) +# --------------------------------------------------------------------------- + +from ferro_ta.tools.gpu import ema as gpu_ema +from ferro_ta.tools.gpu import rsi as gpu_rsi +from ferro_ta.tools.gpu import sma as gpu_sma # noqa: E402 + +CLOSE_15 = 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, + ] +) + + +class TestGPUCPUFallback: + """GPU module falls back to CPU when CuPy is not available.""" + + def test_sma_cpu_fallback_length(self): + result = gpu_sma(CLOSE_15, timeperiod=5) + assert len(result) == len(CLOSE_15) + + def test_sma_cpu_fallback_values(self): + from ferro_ta import SMA + + result = gpu_sma(CLOSE_15, timeperiod=5) + expected = SMA(CLOSE_15, timeperiod=5) + np.testing.assert_allclose(result, expected, equal_nan=True) + + def test_ema_cpu_fallback_values(self): + from ferro_ta import EMA + + result = gpu_ema(CLOSE_15, timeperiod=5) + expected = EMA(CLOSE_15, timeperiod=5) + np.testing.assert_allclose(result, expected, equal_nan=True) + + def test_rsi_cpu_fallback_values(self): + from ferro_ta import RSI + + result = gpu_rsi(CLOSE_15, timeperiod=5) + expected = RSI(CLOSE_15, timeperiod=5) + np.testing.assert_allclose(result, expected, equal_nan=True) + + def test_sma_returns_numpy_for_numpy_input(self): + result = gpu_sma(CLOSE_15, timeperiod=5) + assert isinstance(result, np.ndarray) + + def test_rsi_finite_values_in_range(self): + result = gpu_rsi(CLOSE_15, timeperiod=5) + finite = result[np.isfinite(result)] + assert len(finite) > 0 + assert np.all(finite >= 0.0) + assert np.all(finite <= 100.0) + + def test_gpu_module_all_exports(self): + from ferro_ta.tools import gpu as gpu_mod + + for name in gpu_mod.__all__: + assert callable(getattr(gpu_mod, name)) + + +# --------------------------------------------------------------------------- +# Indicator pipeline +# --------------------------------------------------------------------------- + +from ferro_ta import BBANDS # noqa: E402 (already imported) +from ferro_ta.tools.pipeline import Pipeline, make_pipeline # noqa: E402 + +CLOSE_20 = np.random.default_rng(99).random(20) * 100 + 50 + + +class TestPipeline: + """Tests for ferro_ta.pipeline.Pipeline.""" + + def test_pipeline_run_returns_dict(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5) + result = pipe.run(CLOSE_20) + assert isinstance(result, dict) + assert "sma5" in result + + def test_pipeline_result_length_matches_input(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5) + result = pipe.run(CLOSE_20) + assert len(result["sma5"]) == len(CLOSE_20) + + def test_pipeline_multiple_steps(self): + pipe = ( + Pipeline() + .add("sma5", SMA, timeperiod=5) + .add("ema5", EMA, timeperiod=5) + .add("rsi7", RSI, timeperiod=7) + ) + result = pipe.run(CLOSE_20) + assert set(result.keys()) == {"sma5", "ema5", "rsi7"} + + def test_pipeline_multi_output_with_output_keys(self): + pipe = Pipeline().add( + "bb", + BBANDS, + timeperiod=5, + nbdevup=2.0, + nbdevdn=2.0, + output_keys=["upper", "mid", "lower"], + ) + result = pipe.run(CLOSE_20) + assert "upper" in result + assert "mid" in result + assert "lower" in result + assert "bb" not in result + + def test_pipeline_multi_output_without_output_keys(self): + pipe = Pipeline().add("bb", BBANDS, timeperiod=5, nbdevup=2.0, nbdevdn=2.0) + result = pipe.run(CLOSE_20) + # Should auto-name as bb_0, bb_1, bb_2 + assert "bb_0" in result + assert "bb_1" in result + assert "bb_2" in result + + def test_pipeline_remove_step(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5).add("ema5", EMA, timeperiod=5) + pipe.remove("sma5") + assert pipe.steps() == ["ema5"] + + def test_pipeline_len(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5).add("ema5", EMA, timeperiod=5) + assert len(pipe) == 2 + + def test_pipeline_duplicate_name_raises(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5) + with pytest.raises(ValueError, match="sma5"): + pipe.add("sma5", SMA, timeperiod=10) + + def test_make_pipeline_factory(self): + pipe = make_pipeline( + sma5=(SMA, {"timeperiod": 5}), + rsi7=(RSI, {"timeperiod": 7}), + ) + result = pipe.run(CLOSE_20) + assert "sma5" in result + assert "rsi7" in result + + def test_pipeline_sma_values_match_direct_call(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5) + result = pipe.run(CLOSE_20) + direct = SMA(CLOSE_20, timeperiod=5) + np.testing.assert_allclose(result["sma5"], direct, equal_nan=True) + + +# --------------------------------------------------------------------------- +# Polars integration (skipped if polars not installed) +# --------------------------------------------------------------------------- + + +class TestPolarsIntegration: + """Transparent polars.Series support via polars_wrap.""" + + @pytest.fixture(autouse=True) + def skip_if_no_polars(self): + pytest.importorskip("polars") + + def test_sma_returns_polars_series(self): + import polars as pl + + s = pl.Series("close", CLOSE_20.tolist()) + result = SMA(s, timeperiod=5) + assert isinstance(result, pl.Series) + + def test_sma_values_match_numpy(self): + import polars as pl + + s = pl.Series("close", CLOSE_20.tolist()) + result = SMA(s, timeperiod=5) + expected = SMA(CLOSE_20, timeperiod=5) + np.testing.assert_allclose(result.to_numpy(), expected, equal_nan=True) + + def test_rsi_returns_polars_series(self): + import polars as pl + + s = pl.Series("close", CLOSE_20.tolist()) + result = RSI(s, timeperiod=5) + assert isinstance(result, pl.Series) + + def test_numpy_input_still_returns_numpy(self): + result = SMA(CLOSE_20, timeperiod=5) + assert isinstance(result, np.ndarray) + + +# --------------------------------------------------------------------------- +# Configuration defaults +# --------------------------------------------------------------------------- + +import ferro_ta.core.config as ftconfig # noqa: E402 + + +class TestConfig: + """Tests for ferro_ta.config module.""" + + def setup_method(self): + """Reset config state before each test.""" + ftconfig.reset() + + def teardown_method(self): + """Clean up after each test.""" + ftconfig.reset() + + def test_set_and_get_default(self): + ftconfig.set_default("timeperiod", 20) + assert ftconfig.get_default("timeperiod") == 20 + + def test_get_default_fallback(self): + assert ftconfig.get_default("nonexistent") is None + assert ftconfig.get_default("nonexistent", -1) == -1 + + def test_reset_single_key(self): + ftconfig.set_default("timeperiod", 20) + ftconfig.reset("timeperiod") + assert ftconfig.get_default("timeperiod") is None + + def test_reset_all(self): + ftconfig.set_default("timeperiod", 20) + ftconfig.set_default("RSI.timeperiod", 14) + ftconfig.reset() + assert ftconfig.list_defaults() == {} + + def test_list_defaults(self): + ftconfig.set_default("timeperiod", 20) + ftconfig.set_default("RSI.timeperiod", 14) + defaults = ftconfig.list_defaults() + assert defaults == {"timeperiod": 20, "RSI.timeperiod": 14} + + def test_get_defaults_for_indicator(self): + ftconfig.set_default("timeperiod", 20) + ftconfig.set_default("RSI.timeperiod", 14) + rsi_defaults = ftconfig.get_defaults_for("RSI") + assert rsi_defaults == {"timeperiod": 14} + sma_defaults = ftconfig.get_defaults_for("SMA") + assert sma_defaults == {"timeperiod": 20} + + def test_config_context_manager(self): + ftconfig.set_default("timeperiod", 20) + with ftconfig.Config(timeperiod=5): + assert ftconfig.get_default("timeperiod") == 5 + assert ftconfig.get_default("timeperiod") == 20 + + def test_config_context_manager_restores_on_exception(self): + ftconfig.set_default("timeperiod", 20) + try: + with ftconfig.Config(timeperiod=5): + raise RuntimeError("test error") + except RuntimeError: + pass + assert ftconfig.get_default("timeperiod") == 20 + + def test_config_context_manager_new_key_removed_on_exit(self): + # Key doesn't exist before context + assert ftconfig.get_default("nbdevup") is None + with ftconfig.Config(nbdevup=2.5): + assert ftconfig.get_default("nbdevup") == 2.5 + assert ftconfig.get_default("nbdevup") is None diff --git a/ferro-ta-main/tests/unit/test_known_values.py b/ferro-ta-main/tests/unit/test_known_values.py new file mode 100644 index 0000000..382a7eb --- /dev/null +++ b/ferro-ta-main/tests/unit/test_known_values.py @@ -0,0 +1,562 @@ +""" +Known-value oracle tests: permanent ground truth (Priority 2 - no optional deps). + +Hand-computable ground truth that never depends on external libraries. +These tests encode fundamental mathematical properties and serve as a permanent +oracle for correctness. + +All tests use NO optional dependencies - they run in every CI environment. +""" + +from __future__ import annotations + +import numpy as np + +import ferro_ta + +# --------------------------------------------------------------------------- +# SMA Known Values +# --------------------------------------------------------------------------- + + +class TestSMAKnownValues: + """SMA is the simple average over a window.""" + + def test_sma_simple_sequence(self): + """SMA([1,2,3,4,5], 3) == [nan, nan, 2.0, 3.0, 4.0].""" + data = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = ferro_ta.SMA(data, timeperiod=3) + + assert np.isnan(result[0]) + assert np.isnan(result[1]) + assert np.abs(result[2] - 2.0) < 1e-10 # (1+2+3)/3 = 2.0 + assert np.abs(result[3] - 3.0) < 1e-10 # (2+3+4)/3 = 3.0 + assert np.abs(result[4] - 4.0) < 1e-10 # (3+4+5)/3 = 4.0 + + def test_sma_period_one_is_identity(self): + """SMA with period=1 should be the identity function.""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) + result = ferro_ta.SMA(data, timeperiod=1) + + assert np.allclose(result, data, atol=1e-10) + + def test_sma_constant_series(self): + """SMA of constant series should equal that constant.""" + data = np.ones(10) * 42.0 + result = ferro_ta.SMA(data, timeperiod=5) + + # After warmup, all values should be 42.0 + assert np.allclose(result[4:], 42.0, atol=1e-10) + + +# --------------------------------------------------------------------------- +# EMA Known Values +# --------------------------------------------------------------------------- + + +class TestEMAKnownValues: + """EMA is an exponentially weighted moving average.""" + + def test_ema_period_one_is_identity(self): + """EMA with period=1 should be the identity function (alpha=1).""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) + result = ferro_ta.EMA(data, timeperiod=1) + + assert np.allclose(result, data, atol=1e-10) + + def test_ema_constant_series_converges(self): + """EMA of constant series should converge to that constant.""" + data = np.ones(100) * 42.0 + result = ferro_ta.EMA(data, timeperiod=10) + + # After sufficient warmup, should converge to 42.0 + assert np.allclose(result[-10:], 42.0, atol=1e-6) + + def test_ema_monotone_rising_is_increasing(self): + """EMA of monotone rising series should be strictly increasing after warmup.""" + data = np.arange(1.0, 51.0) # 1, 2, 3, ..., 50 + result = ferro_ta.EMA(data, timeperiod=10) + + # After warmup, EMA should be strictly increasing + for i in range(20, len(result) - 1): + assert result[i + 1] > result[i], ( + f"EMA not increasing at index {i}: {result[i]} >= {result[i + 1]}" + ) + + +# --------------------------------------------------------------------------- +# WMA Known Values +# --------------------------------------------------------------------------- + + +class TestWMAKnownValues: + """WMA is a linearly weighted moving average.""" + + def test_wma_manual_calculation(self): + """WMA([3,5,7], 2) at index 2 = (1*5 + 2*7)/(1+2) = 6.333...""" + data = np.array([3.0, 5.0, 7.0]) + result = ferro_ta.WMA(data, timeperiod=2) + + # Index 0: warmup (NaN) + assert np.isnan(result[0]) + + # Index 1: (1*3 + 2*5)/(1+2) = 13/3 = 4.333... + expected_1 = (1 * 3.0 + 2 * 5.0) / (1 + 2) + assert np.abs(result[1] - expected_1) < 1e-10 + + # Index 2: (1*5 + 2*7)/(1+2) = 19/3 = 6.333... + expected_2 = (1 * 5.0 + 2 * 7.0) / (1 + 2) + assert np.abs(result[2] - expected_2) < 1e-10 + + def test_wma_period_one_is_identity(self): + """WMA with period=1 should be the identity function.""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) + result = ferro_ta.WMA(data, timeperiod=1) + + assert np.allclose(result, data, atol=1e-10) + + +# --------------------------------------------------------------------------- +# BBANDS Known Values +# --------------------------------------------------------------------------- + + +class TestBBANDSKnownValues: + """Bollinger Bands: middle = SMA, upper/lower = middle ± (nbdevup/nbdevdn * stddev).""" + + def test_bbands_constant_series(self): + """For constant series: upper == middle == lower (stddev=0).""" + data = np.ones(20) * 50.0 + upper, middle, lower = ferro_ta.BBANDS(data, timeperiod=5) + + # After warmup, all three bands should be 50.0 + assert np.allclose(upper[4:], 50.0, atol=1e-10) + assert np.allclose(middle[4:], 50.0, atol=1e-10) + assert np.allclose(lower[4:], 50.0, atol=1e-10) + + def test_bbands_middle_is_sma(self): + """Middle band should equal SMA.""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0, 14.0, 16.0, 12.0]) + upper, middle, lower = ferro_ta.BBANDS(data, timeperiod=5) + sma = ferro_ta.SMA(data, timeperiod=5) + + assert np.allclose(middle, sma, atol=1e-10, equal_nan=True) + + def test_bbands_symmetric(self): + """Bands should be symmetric: upper-middle == middle-lower (with same nbdev).""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0, 14.0, 16.0, 12.0, 18.0, 10.0]) + upper, middle, lower = ferro_ta.BBANDS( + data, timeperiod=5, nbdevup=2.0, nbdevdn=2.0 + ) + + # After warmup, bands should be symmetric + upper_dist = upper[4:] - middle[4:] + lower_dist = middle[4:] - lower[4:] + + assert np.allclose(upper_dist, lower_dist, atol=1e-10) + + +# --------------------------------------------------------------------------- +# RSI Known Values +# --------------------------------------------------------------------------- + + +class TestRSIKnownValues: + """RSI measures momentum: monotone rising → RSI > 50, monotone falling → RSI < 50.""" + + def test_rsi_monotone_rising(self): + """Monotone rising series should produce RSI > 50 after warmup.""" + data = np.arange(1.0, 51.0) # 1, 2, 3, ..., 50 + result = ferro_ta.RSI(data, timeperiod=14) + + # After warmup, RSI should be > 50 (strong uptrend) + assert np.all(result[20:] > 50.0), "RSI of rising series should be > 50" + + def test_rsi_monotone_falling(self): + """Monotone falling series should produce RSI < 50 after warmup.""" + data = np.arange(50.0, 0.0, -1.0) # 50, 49, 48, ..., 1 + result = ferro_ta.RSI(data, timeperiod=14) + + # After warmup, RSI should be < 50 (strong downtrend) + assert np.all(result[20:] < 50.0), "RSI of falling series should be < 50" + + def test_rsi_constant_series(self): + """Constant series should produce RSI = 100 or NaN (no momentum). + + Note: For constant series with no change, ferro_ta returns 100 + (no downward movement), which is mathematically correct. + """ + data = np.ones(30) * 42.0 + result = ferro_ta.RSI(data, timeperiod=14) + + # Constant series has no momentum; RSI should be NaN or 100 + # ferro_ta returns 100 (no down movement = 100% bullish) + valid_values = result[~np.isnan(result)] + if len(valid_values) > 0: + # Should be either NaN everywhere or 100 everywhere + assert np.all(np.abs(valid_values - 100.0) < 1e-10) or np.all( + np.abs(valid_values - 50.0) < 5.0 + ), "RSI of constant series should be 100 (no down movement) or close to 50" + + +# --------------------------------------------------------------------------- +# ATR Known Values +# --------------------------------------------------------------------------- + + +class TestATRKnownValues: + """ATR measures volatility: H==L==C → ATR=0.""" + + def test_atr_zero_range(self): + """When H==L==C, ATR should be 0 (no volatility).""" + n = 30 + high = np.ones(n) * 50.0 + low = np.ones(n) * 50.0 + close = np.ones(n) * 50.0 + + result = ferro_ta.ATR(high, low, close, timeperiod=14) + + # After warmup, ATR should be 0 + assert np.allclose(result[14:], 0.0, atol=1e-10) + + def test_atr_manual_tr_calculation(self): + """Manually verify TR formula for 3-bar sequence. + + Note: ATR requires warmup period. For period=14, first 13 bars are NaN. + We test with longer period to see TR values. + """ + # Bar 0: H=11, L=9, C=10 + # Bar 1: H=13, L=10, C=12 → TR = max(13-10, |13-10|, |10-10|) = 3 + # Bar 2: H=14, L=11, C=13 → TR = max(14-11, |14-12|, |11-12|) = 3 + high = np.array( + [ + 11.0, + 13.0, + 14.0, + 15.0, + 16.0, + 17.0, + 18.0, + 19.0, + 20.0, + 21.0, + 22.0, + 23.0, + 24.0, + 25.0, + 26.0, + ] + ) + low = np.array( + [ + 9.0, + 10.0, + 11.0, + 12.0, + 13.0, + 14.0, + 15.0, + 16.0, + 17.0, + 18.0, + 19.0, + 20.0, + 21.0, + 22.0, + 23.0, + ] + ) + close = np.array( + [ + 10.0, + 12.0, + 13.0, + 14.0, + 15.0, + 16.0, + 17.0, + 18.0, + 19.0, + 20.0, + 21.0, + 22.0, + 23.0, + 24.0, + 25.0, + ] + ) + + # For period=1, ATR still has warmup. Use TRANGE to check TR values directly + tr = ferro_ta.TRANGE(high, low, close) + + # TR[0] = H-L = 11-9 = 2 + # TR[1] = max(13-10, |13-10|, |10-10|) = max(3, 3, 0) = 3 + # TR[2] = max(14-11, |14-12|, |11-12|) = max(3, 2, 1) = 3 + + assert np.abs(tr[0] - 2.0) < 1e-10 + assert np.abs(tr[1] - 3.0) < 1e-10 + assert np.abs(tr[2] - 3.0) < 1e-10 + + +# --------------------------------------------------------------------------- +# MOM Known Values +# --------------------------------------------------------------------------- + + +class TestMOMKnownValues: + """MOM is the difference: close[i] - close[i - period].""" + + def test_mom_manual_calculation(self): + """MOM([10,12,15,11], period=2) == [nan,nan,5,-1].""" + data = np.array([10.0, 12.0, 15.0, 11.0]) + result = ferro_ta.MOM(data, timeperiod=2) + + assert np.isnan(result[0]) + assert np.isnan(result[1]) + assert np.abs(result[2] - 5.0) < 1e-10 # 15 - 10 = 5 + assert np.abs(result[3] - (-1.0)) < 1e-10 # 11 - 12 = -1 + + +# --------------------------------------------------------------------------- +# ROC Known Values +# --------------------------------------------------------------------------- + + +class TestROCKnownValues: + """ROC is the percentage change: 100 * (close[i] - close[i-period]) / close[i-period].""" + + def test_roc_manual_calculation(self): + """ROC([10,12], period=1)[1] == 20.0.""" + data = np.array([10.0, 12.0]) + result = ferro_ta.ROC(data, timeperiod=1) + + # ROC[1] = 100 * (12 - 10) / 10 = 100 * 0.2 = 20.0 + assert np.abs(result[1] - 20.0) < 1e-10 + + +# --------------------------------------------------------------------------- +# MACD Known Values +# --------------------------------------------------------------------------- + + +class TestMACDKnownValues: + """MACD: histogram == macd - signal always.""" + + def test_macd_histogram_identity(self): + """histogram should always equal macd - signal.""" + data = np.arange(1.0, 51.0) + macd, signal, histogram = ferro_ta.MACD( + data, fastperiod=12, slowperiod=26, signalperiod=9 + ) + + # histogram = macd - signal (within floating-point tolerance) + expected_histogram = macd - signal + assert np.allclose(histogram, expected_histogram, atol=1e-10, equal_nan=True) + + +# --------------------------------------------------------------------------- +# VWAP Known Values +# --------------------------------------------------------------------------- + + +class TestVWAPKnownValues: + """VWAP: period=1 VWAP == TYPPRICE.""" + + def test_vwap_period_one_equals_typprice(self): + """For period=1, VWAP should equal typical price (H+L+C)/3.""" + high = np.array([11.0, 13.0, 14.0]) + low = np.array([9.0, 10.0, 11.0]) + close = np.array([10.0, 12.0, 13.0]) + volume = np.array([1000.0, 1000.0, 1000.0]) + + result = ferro_ta.VWAP(high, low, close, volume, timeperiod=1) + expected = ferro_ta.TYPPRICE(high, low, close) + + assert np.allclose(result, expected, atol=1e-10) + + def test_vwap_cumulative_manual(self): + """Manually verify cumulative VWAP for simple 3-bar sequence.""" + # Bar 0: TP=10, Vol=100 → VWAP = (10*100)/(100) = 10.0 + # Bar 1: TP=12, Vol=200 → VWAP = (10*100 + 12*200)/(100+200) = 3400/300 = 11.333... + # Bar 2: TP=11, Vol=150 → VWAP = (10*100 + 12*200 + 11*150)/(100+200+150) = 5050/450 = 11.222... + high = np.array([11.0, 13.0, 12.0]) + low = np.array([9.0, 11.0, 10.0]) + close = np.array([10.0, 12.0, 11.0]) + volume = np.array([100.0, 200.0, 150.0]) + + result = ferro_ta.VWAP(high, low, close, volume, timeperiod=0) # cumulative + + typ = (high + low + close) / 3.0 + + expected_0 = typ[0] + expected_1 = (typ[0] * volume[0] + typ[1] * volume[1]) / (volume[0] + volume[1]) + expected_2 = (typ[0] * volume[0] + typ[1] * volume[1] + typ[2] * volume[2]) / ( + volume[0] + volume[1] + volume[2] + ) + + assert np.abs(result[0] - expected_0) < 1e-10 + assert np.abs(result[1] - expected_1) < 1e-10 + assert np.abs(result[2] - expected_2) < 1e-10 + + +# --------------------------------------------------------------------------- +# DONCHIAN Known Values +# --------------------------------------------------------------------------- + + +class TestDONCHIANKnownValues: + """DONCHIAN: upper = MAX(high), lower = MIN(low), middle = (upper+lower)/2.""" + + def test_donchian_structure(self): + """upper == MAX(high), lower == MIN(low), middle == (upper+lower)/2.""" + high = np.array([11.0, 13.0, 14.0, 12.0, 15.0]) + low = np.array([9.0, 10.0, 11.0, 10.0, 12.0]) + + period = 3 + upper, middle, lower = ferro_ta.DONCHIAN(high, low, timeperiod=period) + + # upper should match rolling max of high + max_high = ferro_ta.MAX(high, timeperiod=period) + assert np.allclose(upper, max_high, atol=1e-10, equal_nan=True) + + # lower should match rolling min of low + min_low = ferro_ta.MIN(low, timeperiod=period) + assert np.allclose(lower, min_low, atol=1e-10, equal_nan=True) + + # middle should be (upper + lower) / 2 + expected_middle = (upper + lower) / 2.0 + assert np.allclose(middle, expected_middle, atol=1e-10, equal_nan=True) + + +# --------------------------------------------------------------------------- +# PIVOT_POINTS Known Values +# --------------------------------------------------------------------------- + + +class TestPIVOT_POINTSKnownValues: + """PIVOT_POINTS classic formula: P=(H+L+C)/3, R1=2P-L, S1=2P-H, R2=P+(H-L), S2=P-(H-L).""" + + def test_pivot_points_classic_formula(self): + """Given H=110, L=90, C=100: P=100, R1=110, S1=90, R2=120, S2=80. + + Note: PIVOT_POINTS operates on OHLC bars. Single bar produces valid pivots. + """ + high = np.array([110.0, 110.0]) # Need at least 2 bars + low = np.array([90.0, 90.0]) + close = np.array([100.0, 100.0]) + + pivot, r1, s1, r2, s2 = ferro_ta.PIVOT_POINTS( + high, low, close, method="classic" + ) + + # Check last bar (index 1) which has full history + # P = (110 + 90 + 100) / 3 = 100 + assert np.abs(pivot[1] - 100.0) < 1e-10 + + # R1 = 2*P - L = 2*100 - 90 = 110 + assert np.abs(r1[1] - 110.0) < 1e-10 + + # S1 = 2*P - H = 2*100 - 110 = 90 + assert np.abs(s1[1] - 90.0) < 1e-10 + + # R2 = P + (H - L) = 100 + 20 = 120 + assert np.abs(r2[1] - 120.0) < 1e-10 + + # S2 = P - (H - L) = 100 - 20 = 80 + assert np.abs(s2[1] - 80.0) < 1e-10 + + +# --------------------------------------------------------------------------- +# Statistic Known Values +# --------------------------------------------------------------------------- + + +class TestStatisticKnownValues: + """Statistical functions: correlation, linear regression.""" + + def test_linearreg_slope_of_linear_sequence(self): + """LINEARREG_SLOPE([0,1,2,3,4], 5) == 1.0.""" + data = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + result = ferro_ta.LINEARREG_SLOPE(data, timeperiod=5) + + # Last value should be slope = 1.0 + assert np.abs(result[-1] - 1.0) < 1e-10 + + def test_correl_x_with_x_is_one(self): + """CORREL(x, x) should be 1.0.""" + x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]) + result = ferro_ta.CORREL(x, x, timeperiod=5) + + # After warmup, correlation should be 1.0 + assert np.allclose(result[4:], 1.0, atol=1e-10) + + def test_correl_x_with_negative_x_is_minus_one(self): + """CORREL(x, -x) should be -1.0.""" + x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]) + neg_x = -x + result = ferro_ta.CORREL(x, neg_x, timeperiod=5) + + # After warmup, correlation should be -1.0 + assert np.allclose(result[4:], -1.0, atol=1e-10) + + +# --------------------------------------------------------------------------- +# Pattern Known Values +# --------------------------------------------------------------------------- + + +class TestPatternKnownValues: + """Candlestick patterns: construct known-good OHLC sequences.""" + + def test_doji_known_sequence(self): + """Construct a perfect doji: open == close, small body.""" + # Doji: open == close (or very close), H and L have range + high = np.array([11.0, 11.0, 11.0, 11.0, 11.0]) + low = np.array([9.0, 9.0, 9.0, 9.0, 9.0]) + close = np.array([10.0, 10.0, 10.0, 10.0, 10.0]) + open_ = np.array([10.0, 10.0, 10.0, 10.0, 10.0]) + + result = ferro_ta.CDLDOJI(open_, high, low, close) + + # Should detect doji (non-zero pattern) + # At least some values should be non-zero + assert np.any(result != 0), "CDLDOJI should detect perfect doji pattern" + + def test_engulfing_known_sequence(self): + """Construct a bullish engulfing pattern.""" + # Bullish engulfing: bar[i-1] is bearish (O > C), bar[i] is bullish (C > O) and engulfs bar[i-1] + # Bar 0: O=12, H=12, L=10, C=10 (bearish) + # Bar 1: O=9, H=13, L=9, C=13 (bullish, engulfs bar 0) + open_ = np.array([12.0, 9.0]) + high = np.array([12.0, 13.0]) + low = np.array([10.0, 9.0]) + close = np.array([10.0, 13.0]) + + result = ferro_ta.CDLENGULFING(open_, high, low, close) + + # Should detect engulfing at index 1 + assert result[1] != 0, "CDLENGULFING should detect bullish engulfing pattern" + + def test_hammer_known_sequence(self): + """Construct a hammer pattern: small body at top, long lower shadow.""" + # Hammer: small body, long lower shadow (>= 2x body), little/no upper shadow + # O=11, H=11.5, L=9, C=11 → body=0, lower_shadow=2, upper_shadow=0.5 + open_ = np.array([11.0]) + high = np.array([11.5]) + low = np.array([9.0]) + close = np.array([11.0]) + + result = ferro_ta.CDLHAMMER(open_, high, low, close) + + # Should detect hammer (non-zero) + # Note: hammer detection depends on lookback, so we test multiple bars + open_ = np.array([10.0, 10.5, 11.0]) + high = np.array([10.5, 11.0, 11.5]) + low = np.array([9.5, 10.0, 9.0]) + close = np.array([10.0, 10.5, 11.0]) + + result = ferro_ta.CDLHAMMER(open_, high, low, close) + + # Last bar has hammer characteristics + # (actual detection may vary based on implementation) + assert result.shape == close.shape diff --git a/ferro-ta-main/tests/unit/test_math_ops_vs_numpy.py b/ferro-ta-main/tests/unit/test_math_ops_vs_numpy.py new file mode 100644 index 0000000..b3e8504 --- /dev/null +++ b/ferro-ta-main/tests/unit/test_math_ops_vs_numpy.py @@ -0,0 +1,357 @@ +""" +Comparison tests: ferro_ta.math_ops vs NumPy (Priority 1 - no optional deps). + +Math operators should be exact numpy wrappers. Zero tolerance for deviation. + +This module validates that all math operators and transforms in ferro_ta.math_ops +produce identical results to their NumPy equivalents within strict tolerances: + - Element-wise transforms: atol=1e-14 (direct numpy calls) + - Binary operators: atol=1e-14 (direct numpy calls) + - Rolling operators: atol=1e-12 (float sum reordering) + - Index operators: exact index matching + +All tests use NO optional dependencies - they run in every CI environment. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from ferro_ta.indicators import math_ops + +# --------------------------------------------------------------------------- +# Test Data (seeded for reproducibility) +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +N = 100 + +# Standard test data +CLOSE = 44.0 + np.cumsum(RNG.standard_normal(N) * 0.5) +CLOSE_POSITIVE = np.abs(CLOSE) + 1.0 # For SQRT, LN, LOG10 +CLOSE_NORMALIZED = CLOSE / np.max(np.abs(CLOSE)) # For ASIN, ACOS (range [-1, 1]) + + +# --------------------------------------------------------------------------- +# Element-wise Transform Tests +# --------------------------------------------------------------------------- + + +class TestElementWiseTransforms: + """Test all 15 unary math transforms against NumPy equivalents. + + Expected tolerance: atol=1e-14 (direct numpy calls) + """ + + def test_sin_exact_match(self): + """SIN should match np.sin exactly.""" + result = math_ops.SIN(CLOSE) + expected = np.sin(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_cos_exact_match(self): + """COS should match np.cos exactly.""" + result = math_ops.COS(CLOSE) + expected = np.cos(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_tan_exact_match(self): + """TAN should match np.tan exactly.""" + result = math_ops.TAN(CLOSE) + expected = np.tan(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_sinh_exact_match(self): + """SINH should match np.sinh exactly.""" + result = math_ops.SINH(CLOSE) + expected = np.sinh(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_cosh_exact_match(self): + """COSH should match np.cosh exactly.""" + result = math_ops.COSH(CLOSE) + expected = np.cosh(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_tanh_exact_match(self): + """TANH should match np.tanh exactly.""" + result = math_ops.TANH(CLOSE) + expected = np.tanh(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_asin_exact_match(self): + """ASIN should match np.arcsin exactly.""" + result = math_ops.ASIN(CLOSE_NORMALIZED) + expected = np.arcsin(CLOSE_NORMALIZED) + assert np.allclose(result, expected, atol=1e-14) + + def test_acos_exact_match(self): + """ACOS should match np.arccos exactly.""" + result = math_ops.ACOS(CLOSE_NORMALIZED) + expected = np.arccos(CLOSE_NORMALIZED) + assert np.allclose(result, expected, atol=1e-14) + + def test_atan_exact_match(self): + """ATAN should match np.arctan exactly.""" + result = math_ops.ATAN(CLOSE) + expected = np.arctan(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_exp_exact_match(self): + """EXP should match np.exp exactly.""" + # Use smaller values to avoid overflow + small_values = CLOSE / 10.0 + result = math_ops.EXP(small_values) + expected = np.exp(small_values) + assert np.allclose(result, expected, atol=1e-14) + + def test_ln_exact_match(self): + """LN should match np.log exactly.""" + result = math_ops.LN(CLOSE_POSITIVE) + expected = np.log(CLOSE_POSITIVE) + assert np.allclose(result, expected, atol=1e-14) + + def test_log10_exact_match(self): + """LOG10 should match np.log10 exactly.""" + result = math_ops.LOG10(CLOSE_POSITIVE) + expected = np.log10(CLOSE_POSITIVE) + assert np.allclose(result, expected, atol=1e-14) + + def test_sqrt_exact_match(self): + """SQRT should match np.sqrt exactly.""" + result = math_ops.SQRT(CLOSE_POSITIVE) + expected = np.sqrt(CLOSE_POSITIVE) + assert np.allclose(result, expected, atol=1e-14) + + def test_ceil_exact_match(self): + """CEIL should match np.ceil exactly.""" + result = math_ops.CEIL(CLOSE) + expected = np.ceil(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_floor_exact_match(self): + """FLOOR should match np.floor exactly.""" + result = math_ops.FLOOR(CLOSE) + expected = np.floor(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + +# --------------------------------------------------------------------------- +# Binary Operator Tests +# --------------------------------------------------------------------------- + + +class TestBinaryOps: + """Test binary operators against NumPy equivalents. + + Expected tolerance: atol=1e-14 (direct numpy calls) + """ + + def test_add_exact_match(self): + """ADD should match np.add exactly.""" + other = RNG.standard_normal(N) + result = math_ops.ADD(CLOSE, other) + expected = np.add(CLOSE, other) + assert np.allclose(result, expected, atol=1e-14) + + def test_sub_exact_match(self): + """SUB should match np.subtract exactly.""" + other = RNG.standard_normal(N) + result = math_ops.SUB(CLOSE, other) + expected = np.subtract(CLOSE, other) + assert np.allclose(result, expected, atol=1e-14) + + def test_mult_exact_match(self): + """MULT should match np.multiply exactly.""" + other = RNG.standard_normal(N) + result = math_ops.MULT(CLOSE, other) + expected = np.multiply(CLOSE, other) + assert np.allclose(result, expected, atol=1e-14) + + def test_div_exact_match(self): + """DIV should match np.divide exactly.""" + other = RNG.uniform(0.5, 2.0, N) # Avoid division by zero + result = math_ops.DIV(CLOSE, other) + expected = np.divide(CLOSE, other) + assert np.allclose(result, expected, atol=1e-14) + + +# --------------------------------------------------------------------------- +# Rolling Operator Tests +# --------------------------------------------------------------------------- + + +class TestRollingOps: + """Test rolling operators against pandas equivalents. + + Expected tolerance: atol=1e-12 (float sum reordering) + """ + + @pytest.mark.parametrize("period", [5, 10, 20, 30]) + def test_sum_matches_pandas_rolling(self, period): + """SUM should match pd.Series.rolling(p).sum().""" + result = math_ops.SUM(CLOSE, timeperiod=period) + expected = pd.Series(CLOSE).rolling(period).sum().to_numpy() + + # Check NaN positions match + assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected)) + + # Check values match where both are finite + mask = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[mask], expected[mask], atol=1e-12) + + @pytest.mark.parametrize("period", [5, 10, 20, 30]) + def test_max_matches_pandas_rolling(self, period): + """MAX should match pd.Series.rolling(p).max().""" + result = math_ops.MAX(CLOSE, timeperiod=period) + expected = pd.Series(CLOSE).rolling(period).max().to_numpy() + + # Check NaN positions match + assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected)) + + # Check values match where both are finite + mask = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[mask], expected[mask], atol=1e-12) + + @pytest.mark.parametrize("period", [5, 10, 20, 30]) + def test_min_matches_pandas_rolling(self, period): + """MIN should match pd.Series.rolling(p).min().""" + result = math_ops.MIN(CLOSE, timeperiod=period) + expected = pd.Series(CLOSE).rolling(period).min().to_numpy() + + # Check NaN positions match + assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected)) + + # Check values match where both are finite + mask = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[mask], expected[mask], atol=1e-12) + + +# --------------------------------------------------------------------------- +# Index Operator Tests +# --------------------------------------------------------------------------- + + +class TestIndexOps: + """Test MAXINDEX and MININDEX point to correct argmax/argmin in window.""" + + @pytest.mark.parametrize("period", [5, 10, 20]) + def test_maxindex_points_to_max(self, period): + """MAXINDEX should point to the index of the rolling maximum.""" + result_idx = math_ops.MAXINDEX(CLOSE, timeperiod=period) + result_max = math_ops.MAX(CLOSE, timeperiod=period) + + # Skip warmup period + for i in range(period - 1, N): + idx = result_idx[i] + max_val = result_max[i] + + # During warmup, index is -1 + if idx == -1: + assert np.isnan(max_val) + else: + # Index should point to the actual maximum in the window + assert CLOSE[idx] == max_val, ( + f"At position {i}, MAXINDEX={idx} but CLOSE[{idx}]={CLOSE[idx]} " + f"!= MAX={max_val}" + ) + + @pytest.mark.parametrize("period", [5, 10, 20]) + def test_minindex_points_to_min(self, period): + """MININDEX should point to the index of the rolling minimum.""" + result_idx = math_ops.MININDEX(CLOSE, timeperiod=period) + result_min = math_ops.MIN(CLOSE, timeperiod=period) + + # Skip warmup period + for i in range(period - 1, N): + idx = result_idx[i] + min_val = result_min[i] + + # During warmup, index is -1 + if idx == -1: + assert np.isnan(min_val) + else: + # Index should point to the actual minimum in the window + assert CLOSE[idx] == min_val, ( + f"At position {i}, MININDEX={idx} but CLOSE[{idx}]={CLOSE[idx]} " + f"!= MIN={min_val}" + ) + + def test_maxindex_warmup_returns_minus_one(self): + """MAXINDEX should return -1 during warmup period.""" + period = 10 + result = math_ops.MAXINDEX(CLOSE, timeperiod=period) + + # First period-1 values should be -1 + for i in range(period - 1): + assert result[i] == -1, f"Expected -1 at index {i}, got {result[i]}" + + def test_minindex_warmup_returns_minus_one(self): + """MININDEX should return -1 during warmup period.""" + period = 10 + result = math_ops.MININDEX(CLOSE, timeperiod=period) + + # First period-1 values should be -1 + for i in range(period - 1): + assert result[i] == -1, f"Expected -1 at index {i}, got {result[i]}" + + +# --------------------------------------------------------------------------- +# Edge Case Tests +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + """Test edge cases and document behavior. + + Documents behavior for: + - LN(negative) → NaN + - SQRT(negative) → NaN + - DIV(by zero) → inf + - ACOS(>1) → NaN + """ + + def test_ln_negative_returns_nan(self): + """LN of negative values should return NaN.""" + negative = np.array([-1.0, -2.0, -3.0]) + result = math_ops.LN(negative) + assert np.all(np.isnan(result)), "LN(negative) should return NaN" + + def test_sqrt_negative_returns_nan(self): + """SQRT of negative values should return NaN.""" + negative = np.array([-1.0, -4.0, -9.0]) + result = math_ops.SQRT(negative) + assert np.all(np.isnan(result)), "SQRT(negative) should return NaN" + + def test_div_by_zero_returns_inf(self): + """DIV by zero should return inf (NumPy behavior).""" + numerator = np.array([1.0, 2.0, 3.0]) + denominator = np.array([0.0, 0.0, 0.0]) + result = math_ops.DIV(numerator, denominator) + assert np.all(np.isinf(result)), "DIV(by zero) should return inf" + + def test_acos_out_of_range_returns_nan(self): + """ACOS of values outside [-1, 1] should return NaN.""" + out_of_range = np.array([1.5, 2.0, -1.5]) + result = math_ops.ACOS(out_of_range) + assert np.all(np.isnan(result)), "ACOS(>1 or <-1) should return NaN" + + def test_asin_out_of_range_returns_nan(self): + """ASIN of values outside [-1, 1] should return NaN.""" + out_of_range = np.array([1.5, 2.0, -1.5]) + result = math_ops.ASIN(out_of_range) + assert np.all(np.isnan(result)), "ASIN(>1 or <-1) should return NaN" + + def test_log10_zero_returns_negative_inf(self): + """LOG10(0) should return -inf.""" + zero = np.array([0.0]) + result = math_ops.LOG10(zero) + assert np.isinf(result[0]) and result[0] < 0, "LOG10(0) should return -inf" + + def test_ln_zero_returns_negative_inf(self): + """LN(0) should return -inf.""" + zero = np.array([0.0]) + result = math_ops.LN(zero) + assert np.isinf(result[0]) and result[0] < 0, "LN(0) should return -inf" diff --git a/ferro-ta-main/tests/unit/test_optional_dependency_wrappers.py b/ferro-ta-main/tests/unit/test_optional_dependency_wrappers.py new file mode 100644 index 0000000..ed7b03f --- /dev/null +++ b/ferro-ta-main/tests/unit/test_optional_dependency_wrappers.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from unittest.mock import patch + +import numpy as np + +from ferro_ta._utils import ( + _optional_pandas_module, + _optional_polars_module, + pandas_wrap, + polars_wrap, +) + + +def _missing_only(module_name: str): + real_import = __import__ + attempts: list[str] = [] + + def side_effect(name, globals=None, locals=None, fromlist=(), level=0): + if name == module_name: + attempts.append(name) + raise ImportError(f"{module_name} not installed") + return real_import(name, globals, locals, fromlist, level) + + return attempts, side_effect + + +def test_pandas_wrap_caches_missing_optional_import() -> None: + _optional_pandas_module.cache_clear() + wrapped = pandas_wrap(lambda arr: arr) + arr = np.array([1.0, 2.0, 3.0], dtype=np.float64) + attempts, side_effect = _missing_only("pandas") + + try: + with patch("builtins.__import__", side_effect=side_effect): + np.testing.assert_array_equal(wrapped(arr), arr) + np.testing.assert_array_equal(wrapped(arr), arr) + finally: + _optional_pandas_module.cache_clear() + + assert attempts == ["pandas"] + + +def test_polars_wrap_caches_missing_optional_import() -> None: + _optional_polars_module.cache_clear() + wrapped = polars_wrap(lambda arr: arr) + arr = np.array([1.0, 2.0, 3.0], dtype=np.float64) + attempts, side_effect = _missing_only("polars") + + try: + with patch("builtins.__import__", side_effect=side_effect): + np.testing.assert_array_equal(wrapped(arr), arr) + np.testing.assert_array_equal(wrapped(arr), arr) + finally: + _optional_polars_module.cache_clear() + + assert attempts == ["polars"] diff --git a/ferro-ta-main/tests/unit/test_property_based.py b/ferro-ta-main/tests/unit/test_property_based.py new file mode 100644 index 0000000..5550516 --- /dev/null +++ b/ferro-ta-main/tests/unit/test_property_based.py @@ -0,0 +1,263 @@ +"""Property-based tests (Hypothesis) for ferro-ta.""" + +import numpy as np +import pytest + +from ferro_ta import ATR, BBANDS, CDLDOJI, EMA, MACD, OBV, RSI, SMA, WMA + +try: + from hypothesis import given, settings + from hypothesis.strategies import floats, integers, lists + + HAS_HYPOTHESIS = True +except ImportError: + HAS_HYPOTHESIS = False + +if HAS_HYPOTHESIS: + # Strategy: finite floats, reasonable length + finite_floats = floats( + min_value=1e-6, max_value=1e6, allow_nan=False, allow_infinity=False + ) + price_arrays = lists(finite_floats, min_size=2, max_size=500).map(np.array) + periods = integers(min_value=1, max_value=100) + + @given(price_arrays, periods) + @settings(max_examples=50, deadline=5000) + def test_sma_output_length_matches_input(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = SMA(close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given(price_arrays, periods) + @settings(max_examples=50, deadline=5000) + def test_ema_output_length_matches_input(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = EMA(close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given(price_arrays, periods) + @settings(max_examples=50, deadline=5000) + def test_rsi_output_length_matches_input(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = RSI(close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given(price_arrays, periods) + @settings(max_examples=30, deadline=5000) + def test_bbands_three_outputs_same_length(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + upper, middle, lower = BBANDS(close, timeperiod=timeperiod) + assert len(upper) == len(close) + assert len(middle) == len(close) + assert len(lower) == len(close) + + @given( + lists(finite_floats, min_size=3, max_size=100).map(np.array), + lists(finite_floats, min_size=3, max_size=100).map(np.array), + lists(finite_floats, min_size=3, max_size=100).map(np.array), + lists(finite_floats, min_size=3, max_size=100).map(np.array), + ) + @settings(max_examples=20, deadline=5000) + def test_cdl_pattern_output_values_in_set(open_, high, low, close): + n = min(len(open_), len(high), len(low), len(close)) + open_ = open_[:n] + high = high[:n] + low = low[:n] + close = close[:n] + result = CDLDOJI(open_, high, low, close) + 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: + """Placeholder for running property-based tests as a class.""" + + def test_import(self): + assert HAS_HYPOTHESIS diff --git a/ferro-ta-main/tests/unit/test_tools_and_api.py b/ferro-ta-main/tests/unit/test_tools_and_api.py new file mode 100644 index 0000000..d91f228 --- /dev/null +++ b/ferro-ta-main/tests/unit/test_tools_and_api.py @@ -0,0 +1,1519 @@ +"""Tests for alerts, crypto helpers, chunked processing, +regime detection, performance attribution, and dashboard helpers. +""" + +from __future__ import annotations + +import importlib +import runpy + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Synthetic helpers +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(31415) + + +def _make_close(n: int = 200) -> np.ndarray: + return np.cumprod(1 + RNG.normal(0, 0.01, n)) * 100.0 + + +def _make_ohlcv(n: int = 200): + close = _make_close(n) + open_ = close * RNG.uniform(0.995, 1.005, n) + high = np.maximum(close, open_) + RNG.uniform(0, 0.5, n) + low = np.minimum(close, open_) - RNG.uniform(0, 0.5, n) + volume = RNG.uniform(500, 5000, n) + return open_, high, low, close, volume + + +# =========================================================================== +# Alerts +# =========================================================================== + + +class TestAlertsLowLevel: + """Tests for low-level alert condition functions.""" + + def test_check_threshold_cross_above(self): + from ferro_ta.tools.alerts import check_threshold + + series = np.array([20.0, 25.0, 30.0, 35.0, 28.0]) + mask = check_threshold(series, level=29.0, direction=1) + # Cross above fires when series was <= level and now > level. + # Bar 0: no prior bar, always 0. + # Bar 2: prev=25 <= 29, curr=30 > 29 → fires + assert mask[0] == 0 + assert mask[2] == 1 + assert mask[1] == 0 + assert mask[3] == 0 # 30 > 29 previously, so no new crossing + + def test_check_threshold_cross_below(self): + from ferro_ta.tools.alerts import check_threshold + + series = np.array([70.0, 65.0, 28.0, 25.0, 35.0]) + mask = check_threshold(series, level=30.0, direction=-1) + # index 2: 65 >= 30 → 28 < 30: cross below + assert mask[2] == 1 + assert mask[0] == 0 + assert mask[4] == 0 # 25 < 30 already, 35 > 30 is not a cross-below + + def test_check_threshold_invalid_direction(self): + from ferro_ta.tools.alerts import check_threshold + + with pytest.raises(Exception): + check_threshold(np.array([1.0, 2.0]), level=1.5, direction=0) + + def test_check_cross_bullish(self): + from ferro_ta.tools.alerts import check_cross + + fast = np.array([10.0, 12.0, 15.0, 14.0, 16.0]) + slow = np.array([13.0, 13.0, 13.0, 13.0, 13.0]) + mask = check_cross(fast, slow) + # fast crosses above slow at index 2 (12 <= 13 → 15 > 13) + assert mask[2] == 1 # bullish + assert mask[0] == 0 + + def test_check_cross_bearish(self): + from ferro_ta.tools.alerts import check_cross + + fast = np.array([15.0, 15.0, 12.0, 11.0]) + slow = np.array([13.0, 13.0, 13.0, 13.0]) + mask = check_cross(fast, slow) + # fast crosses below slow at index 2 (15 >= 13 → 12 < 13) + assert mask[2] == -1 # bearish + + def test_check_cross_length_mismatch_raises(self): + from ferro_ta.tools.alerts import check_cross + + with pytest.raises(Exception): + check_cross(np.array([1.0, 2.0]), np.array([1.0, 2.0, 3.0])) + + def test_collect_alert_bars(self): + from ferro_ta.tools.alerts import collect_alert_bars + + mask = np.array([0, 1, 0, 0, 1, -1], dtype=np.int8) + bars = collect_alert_bars(mask) + assert list(bars) == [1, 4, 5] + + def test_collect_alert_bars_empty(self): + from ferro_ta.tools.alerts import collect_alert_bars + + mask = np.zeros(10, dtype=np.int8) + bars = collect_alert_bars(mask) + assert len(bars) == 0 + + +class TestAlertManager: + """Tests for the AlertManager class.""" + + def test_run_backtest_returns_list(self): + from ferro_ta import RSI + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(200) + rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) + am = AlertManager(symbol="TEST") + am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1) + events = am.run_backtest() + assert isinstance(events, list) + + def test_backtest_no_external_calls_by_default(self): + """Backtest mode must not invoke callback unless force_live=True.""" + from ferro_ta import SMA + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(100) + sma10 = np.asarray(SMA(close, timeperiod=10), dtype=np.float64) + sma30 = np.asarray(SMA(close, timeperiod=30), dtype=np.float64) + + called = [] + + def cb(ev): + called.append(ev) + + am = AlertManager() + am.add_cross_condition("sma_x", sma10, sma30, callback=cb) + events = am.run_backtest() # default live=False + assert len(called) == 0, "callback must not fire in backtest mode" + assert isinstance(events, list) + + def test_backtest_force_live_invokes_callback(self): + from ferro_ta import RSI + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(300) + rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) + + fired = [] + + def cb(ev): + fired.append(ev) + + am = AlertManager() + am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1, callback=cb) + events = am.run_backtest(force_live=True) + assert len(fired) == len(events) + + def test_event_payload_contains_symbol(self): + from ferro_ta import RSI + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(200) + rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) + am = AlertManager(symbol="BTCUSD") + am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1) + events = am.run_backtest() + for ev in events: + assert ev.payload.get("symbol") == "BTCUSD" + + def test_event_bar_index_valid(self): + from ferro_ta import SMA + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(100) + sma5 = np.asarray(SMA(close, timeperiod=5), dtype=np.float64) + sma20 = np.asarray(SMA(close, timeperiod=20), dtype=np.float64) + am = AlertManager() + am.add_cross_condition("x", sma5, sma20) + events = am.run_backtest() + for ev in events: + assert 0 <= ev.bar_index < len(close) + + def test_alert_event_to_dict(self): + from ferro_ta.tools.alerts import AlertEvent + + ev = AlertEvent("my_cond", 42, value=27.5, payload={"symbol": "X"}) + d = ev.to_dict() + assert d["condition_id"] == "my_cond" + assert d["bar_index"] == 42 + assert d["symbol"] == "X" + + +# =========================================================================== +# Crypto helpers +# =========================================================================== + + +class TestCryptoFunding: + def test_funding_pnl_shape(self): + from ferro_ta.analysis.crypto import funding_pnl + + pos = np.ones(100) + rate = RNG.normal(0, 0.0001, 100) + pnl = funding_pnl(pos, rate) + assert pnl.shape == (100,) + + def test_funding_pnl_cumulative(self): + from ferro_ta.analysis.crypto import funding_pnl + + pos = np.ones(5) + rate = np.array([0.0001, 0.0002, -0.0001, 0.0001, 0.0001]) + pnl = funding_pnl(pos, rate) + expected = np.cumsum(-pos * rate) + np.testing.assert_allclose(pnl, expected) + + def test_funding_pnl_long_pays_positive_rate(self): + """Long position should pay (negative PnL) when funding rate > 0.""" + from ferro_ta.analysis.crypto import funding_pnl + + pos = np.ones(1) + rate = np.array([0.001]) # positive rate → long pays + pnl = funding_pnl(pos, rate) + assert pnl[0] < 0 + + def test_funding_pnl_short_receives_positive_rate(self): + """Short position should receive (positive PnL) when funding rate > 0.""" + from ferro_ta.analysis.crypto import funding_pnl + + pos = np.array([-1.0]) + rate = np.array([0.001]) + pnl = funding_pnl(pos, rate) + assert pnl[0] > 0 + + def test_funding_pnl_length_mismatch_raises(self): + from ferro_ta.analysis.crypto import funding_pnl + + with pytest.raises(Exception): + funding_pnl(np.ones(5), np.ones(4)) + + +class TestCryptoBarLabels: + def test_continuous_bar_labels_shape(self): + from ferro_ta.analysis.crypto import continuous_bar_labels + + labels = continuous_bar_labels(10, 3) + assert labels.shape == (10,) + + def test_continuous_bar_labels_values(self): + from ferro_ta.analysis.crypto import continuous_bar_labels + + labels = continuous_bar_labels(10, 3) + expected = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3] + np.testing.assert_array_equal(labels, expected) + + def test_continuous_bar_labels_period_one(self): + from ferro_ta.analysis.crypto import continuous_bar_labels + + labels = continuous_bar_labels(5, 1) + np.testing.assert_array_equal(labels, [0, 1, 2, 3, 4]) + + def test_session_boundaries_daily(self): + from ferro_ta.analysis.crypto import session_boundaries + + NS_PER_HOUR = np.int64(3_600_000_000_000) + # Use a UTC midnight timestamp as base: 1_699_920_000 seconds = Nov 14, 2023 00:00:00 UTC + base = np.int64(1_699_920_000) * np.int64(1_000_000_000) # midnight UTC + # 48 hourly bars = 2 full days + ts = base + np.arange(48, dtype=np.int64) * NS_PER_HOUR + bounds = session_boundaries(ts) + assert bounds[0] == 0 # first bar always included + # Should have exactly 2 boundaries (day 0 and day 1) + assert len(bounds) == 2 + assert bounds[1] == 24 # second day starts at bar 24 + + +class TestResampleContinuous: + def test_resample_continuous_shape(self): + from ferro_ta.analysis.crypto import resample_continuous + + o, h, l, c, v = _make_ohlcv(100) + ro, rh, rl, rc, rv = resample_continuous((o, h, l, c, v), period_bars=5) + assert len(rc) == 20 # 100 / 5 + + def test_resample_continuous_high_ge_low(self): + from ferro_ta.analysis.crypto import resample_continuous + + o, h, l, c, v = _make_ohlcv(100) + _, rh, rl, _, _ = resample_continuous((o, h, l, c, v), period_bars=5) + assert np.all(rh >= rl) + + def test_resample_continuous_invalid_period_raises(self): + from ferro_ta.analysis.crypto import resample_continuous + + o, h, l, c, v = _make_ohlcv(10) + with pytest.raises(ValueError): + resample_continuous((o, h, l, c, v), period_bars=0) + + +# =========================================================================== +# Chunked processing +# =========================================================================== + + +class TestChunked: + def test_make_chunk_ranges_shape(self): + from ferro_ta.data.chunked import make_chunk_ranges + + ranges = make_chunk_ranges(100, 30, 10) + assert ranges.ndim == 2 + assert ranges.shape[1] == 2 + + def test_make_chunk_ranges_coverage(self): + """All input indices must be covered by some range.""" + from ferro_ta.data.chunked import make_chunk_ranges + + n = 97 + ranges = make_chunk_ranges(n, 20, 5) + covered = set() + for start, end in ranges: + covered.update(range(int(start), int(end))) + assert 0 in covered + assert (n - 1) in covered + + def test_trim_overlap_basic(self): + from ferro_ta.data.chunked import trim_overlap + + arr = np.arange(10, dtype=np.float64) + trimmed = trim_overlap(arr, overlap=3) + np.testing.assert_array_equal(trimmed, arr[3:]) + + def test_trim_overlap_zero(self): + from ferro_ta.data.chunked import trim_overlap + + arr = np.arange(5, dtype=np.float64) + trimmed = trim_overlap(arr, overlap=0) + np.testing.assert_array_equal(trimmed, arr) + + def test_stitch_chunks_basic(self): + from ferro_ta.data.chunked import stitch_chunks + + a = np.array([1.0, 2.0, 3.0]) + b = np.array([4.0, 5.0]) + result = stitch_chunks([a, b]) + np.testing.assert_array_equal(result, [1, 2, 3, 4, 5]) + + def test_chunk_apply_sma_matches_full(self): + """chunk_apply(SMA, …) should produce the same result as SMA on the full series.""" + from ferro_ta import SMA + from ferro_ta.data.chunked import chunk_apply + + close = _make_close(500) + full_out = np.asarray(SMA(close, timeperiod=20), dtype=np.float64) + chunked_out = chunk_apply(SMA, close, chunk_size=100, overlap=30, timeperiod=20) + + # Compare non-NaN region + valid = ~np.isnan(full_out) + np.testing.assert_allclose( + chunked_out[valid], + full_out[valid], + rtol=1e-10, + err_msg="chunk_apply SMA must match full SMA for non-NaN bars", + ) + + def test_chunk_apply_output_length(self): + from ferro_ta import EMA + from ferro_ta.data.chunked import chunk_apply + + close = _make_close(300) + out = chunk_apply(EMA, close, chunk_size=80, overlap=20, timeperiod=10) + assert len(out) == len(close) + + +# =========================================================================== +# Regime detection +# =========================================================================== + + +class TestRegimeDetection: + def test_regime_adx_shape(self): + from ferro_ta import ADX + from ferro_ta.analysis.regime import regime_adx + + o, h, l, c, v = _make_ohlcv(200) + adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) + labels = regime_adx(adx, threshold=25.0) + assert labels.shape == (200,) + + def test_regime_adx_values_valid(self): + from ferro_ta import ADX + from ferro_ta.analysis.regime import regime_adx + + o, h, l, c, v = _make_ohlcv(200) + adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) + labels = regime_adx(adx, threshold=25.0) + # Values must be -1, 0, or 1 + assert set(labels).issubset({-1, 0, 1}) + + def test_regime_adx_nan_bars_are_minus_one(self): + from ferro_ta.analysis.regime import regime_adx + + adx = np.full(20, np.nan) + adx[15:] = 30.0 # last 5 are trend + labels = regime_adx(adx, threshold=25.0) + assert np.all(labels[:15] == -1) + assert np.all(labels[15:] == 1) + + def test_regime_combined_shape(self): + from ferro_ta import ADX, ATR + from ferro_ta.analysis.regime import regime_combined + + o, h, l, c, v = _make_ohlcv(200) + adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) + atr = np.asarray(ATR(h, l, c, timeperiod=14), dtype=np.float64) + labels = regime_combined( + adx, atr, c, adx_threshold=25.0, atr_pct_threshold=0.005 + ) + assert labels.shape == (200,) + assert set(labels).issubset({-1, 0, 1}) + + def test_regime_high_level_adx(self): + from ferro_ta.analysis.regime import regime + + o, h, l, c, v = _make_ohlcv(200) + labels = regime((o, h, l, c, v), method="adx", adx_threshold=25.0) + assert labels.shape == (200,) + assert set(labels).issubset({-1, 0, 1}) + + def test_regime_high_level_combined(self): + from ferro_ta.analysis.regime import regime + + o, h, l, c, v = _make_ohlcv(200) + labels = regime((o, h, l, c, v), method="combined") + assert labels.shape == (200,) + + def test_regime_unknown_method_raises(self): + from ferro_ta.analysis.regime import regime + + o, h, l, c, v = _make_ohlcv(50) + with pytest.raises(ValueError): + regime((o, h, l, c, v), method="unknown") + + +class TestStructuralBreaks: + def test_detect_breaks_cusum_shape(self): + from ferro_ta.analysis.regime import detect_breaks_cusum + + series = _make_close(200) + mask = detect_breaks_cusum(series, window=20, threshold=3.0, slack=0.5) + assert mask.shape == (200,) + + def test_detect_breaks_cusum_fires_near_break(self): + """CUSUM should detect a level shift.""" + from ferro_ta.analysis.regime import detect_breaks_cusum + + rng = np.random.default_rng(99) + s1 = rng.normal(0, 1, 100) + s2 = rng.normal(10, 1, 100) # large level shift + series = np.concatenate([s1, s2]) + mask = detect_breaks_cusum(series, window=20, threshold=2.0, slack=0.3) + # Should fire somewhere near the shift + assert mask[100:130].any() + + def test_rolling_variance_break_shape(self): + from ferro_ta.analysis.regime import rolling_variance_break + + series = _make_close(200) + mask = rolling_variance_break( + series, short_window=10, long_window=50, threshold=2.0 + ) + assert mask.shape == (200,) + + def test_structural_breaks_cusum(self): + from ferro_ta.analysis.regime import structural_breaks + + series = _make_close(200) + mask = structural_breaks(series, method="cusum") + assert mask.shape == (200,) + + def test_structural_breaks_variance(self): + from ferro_ta.analysis.regime import structural_breaks + + series = _make_close(200) + mask = structural_breaks(series, method="variance") + assert mask.shape == (200,) + + def test_structural_breaks_unknown_method_raises(self): + from ferro_ta.analysis.regime import structural_breaks + + with pytest.raises(ValueError): + structural_breaks(_make_close(50), method="xyz") + + +# =========================================================================== +# Performance attribution +# =========================================================================== + + +class TestTradeStats: + def test_basic_stats(self): + from ferro_ta.analysis.attribution import trade_stats + + pnl = np.array([10.0, -5.0, 8.0, -3.0, 15.0, -2.0]) + hold = np.array([5.0, 3.0, 7.0, 2.0, 10.0, 1.0]) + ts = trade_stats(pnl, hold) + assert ts.n_trades == 6 + assert abs(ts.win_rate - 0.5) < 1e-10 # 3 wins out of 6 + assert ts.avg_win > 0 + assert ts.avg_loss < 0 + assert ts.profit_factor > 0 + assert ts.avg_hold_bars == pytest.approx(4.67, abs=0.01) + + def test_all_wins(self): + from ferro_ta.analysis.attribution import trade_stats + + pnl = np.array([5.0, 10.0, 3.0]) + ts = trade_stats(pnl) + assert ts.win_rate == 1.0 + assert ts.avg_loss == 0.0 + assert ts.profit_factor == float("inf") + + def test_all_losses(self): + from ferro_ta.analysis.attribution import trade_stats + + pnl = np.array([-5.0, -3.0]) + ts = trade_stats(pnl) + assert ts.win_rate == 0.0 + assert ts.avg_win == 0.0 + assert ts.profit_factor == 0.0 + + def test_empty_raises(self): + from ferro_ta.analysis.attribution import trade_stats + + with pytest.raises(Exception): + trade_stats(np.array([])) + + def test_to_dict(self): + from ferro_ta.analysis.attribution import trade_stats + + pnl = np.array([1.0, -1.0]) + ts = trade_stats(pnl) + d = ts.to_dict() + assert "win_rate" in d + assert "profit_factor" in d + + +class TestFromBacktest: + def test_from_backtest_returns_arrays(self): + from ferro_ta.analysis.attribution import from_backtest + from ferro_ta.analysis.backtest import backtest + + close = _make_close(200) + result = backtest(close, strategy="rsi_30_70") + pnl, hold = from_backtest(result) + assert isinstance(pnl, np.ndarray) + assert isinstance(hold, np.ndarray) + assert len(pnl) == len(hold) + # n_trades counts position *changes* (entries + exits); + # from_backtest counts round-trips (position runs), so len(pnl) <= n_trades + assert len(pnl) <= result.n_trades + # Each hold duration should be >= 1 + if len(hold) > 0: + assert np.all(hold >= 1) + + def test_from_backtest_no_trades(self): + from ferro_ta.analysis.attribution import from_backtest + from ferro_ta.analysis.backtest import BacktestResult + + n = 50 + result = BacktestResult( + signals=np.zeros(n), + positions=np.zeros(n), + bar_returns=np.zeros(n), + strategy_returns=np.zeros(n), + equity=np.ones(n), + ) + pnl, hold = from_backtest(result) + assert len(pnl) == 0 + + +class TestAttribution: + def test_attribution_by_signal_basic(self): + from ferro_ta.analysis.attribution import attribution_by_signal + + ret = np.array([0.01, 0.02, -0.01, 0.03, -0.02]) + labels = np.array([0, 0, 1, 1, -1], dtype=np.int64) + contrib = attribution_by_signal(ret, labels) + assert isinstance(contrib, dict) + assert "signal_0" in contrib + assert "signal_1" in contrib + assert abs(contrib["signal_0"] - 0.03) < 1e-10 # 0.01 + 0.02 + assert abs(contrib["signal_1"] - 0.02) < 1e-10 # -0.01 + 0.03 + + def test_attribution_by_month_returns_dict(self): + from ferro_ta.analysis.attribution import attribution_by_month + + ret = RNG.normal(0, 0.01, 252) + contrib = attribution_by_month(ret) + assert isinstance(contrib, dict) + assert len(contrib) > 0 + + def test_attribution_by_month_sum_close_to_total(self): + """Sum of monthly contributions should approximate total strategy return.""" + from ferro_ta.analysis.attribution import attribution_by_month + + ret = RNG.normal(0, 0.01, 252) + contrib = attribution_by_month(ret) + total_monthly = sum(contrib.values()) + total_direct = float(np.sum(ret)) + assert abs(total_monthly - total_direct) < 1e-8 + + +# =========================================================================== +# Dashboard (smoke tests, no display) +# =========================================================================== + + +class TestDashboard: + def test_streamlit_app_import(self): + """Module should import without errors even if streamlit not installed.""" + try: + from ferro_ta.tools import dashboard # noqa: F401 + except ImportError: + pytest.skip("dashboard module not importable") + + def test_indicator_widget_raises_without_ipywidgets(self, monkeypatch): + from ferro_ta import SMA + from ferro_ta.tools.dashboard import indicator_widget + + close = _make_close(50) + # If ipywidgets not installed, should raise ImportError + import sys + + fake_modules = dict(sys.modules) + fake_modules["ipywidgets"] = None # type: ignore[assignment] + fake_modules["matplotlib"] = None # type: ignore[assignment] + fake_modules["matplotlib.pyplot"] = None # type: ignore[assignment] + monkeypatch.setattr(sys, "modules", fake_modules) + with pytest.raises((ImportError, TypeError)): + indicator_widget(close, SMA, "timeperiod", range(5, 10)) + + +# =========================================================================== +# Web API (unit test with TestClient if fastapi is available) +# =========================================================================== + + +class TestWebAPI: + @pytest.fixture(scope="class") + def client(self): + try: + from fastapi.testclient import TestClient + except ImportError: + pytest.skip("fastapi not installed") + import os + import sys + + # Insert project root so that `api.main` is importable + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + if project_root not in sys.path: + sys.path.insert(0, project_root) + try: + from api.main import app + except ImportError: + pytest.skip("api/main.py not importable") + return TestClient(app) + + def test_health(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + def test_sma_endpoint(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post("/indicators/sma", json={"close": close, "timeperiod": 5}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert len(result) == 30 + assert result[0] is None # warm-up is null + + def test_ema_endpoint(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post("/indicators/ema", json={"close": close, "timeperiod": 5}) + assert resp.status_code == 200 + assert len(resp.json()["result"]) == 30 + + def test_rsi_endpoint(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post("/indicators/rsi", json={"close": close, "timeperiod": 14}) + assert resp.status_code == 200 + + def test_macd_endpoint(self, client): + close = list(np.linspace(100, 120, 60)) + resp = client.post("/indicators/macd", json={"close": close}) + assert resp.status_code == 200 + keys = resp.json()["result"].keys() + assert {"macd", "signal", "hist"} == set(keys) + + def test_bbands_endpoint(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post("/indicators/bbands", json={"close": close, "timeperiod": 5}) + assert resp.status_code == 200 + keys = resp.json()["result"].keys() + assert {"upper", "middle", "lower"} == set(keys) + + def test_backtest_endpoint(self, client): + close = list( + np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 + ) + resp = client.post( + "/backtest", + json={"close": close, "strategy": "rsi_30_70"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert "final_equity" in body + assert "n_trades" in body + + def test_unknown_strategy_returns_422(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post( + "/backtest", + json={"close": close, "strategy": "no_such_strategy"}, + ) + assert resp.status_code == 422 + + def test_too_short_series_returns_422(self, client): + resp = client.post("/indicators/sma", json={"close": [100.0], "timeperiod": 5}) + assert resp.status_code == 422 + + +# =========================================================================== +# Benchmark suite sanity +# =========================================================================== + + +class TestBenchmarkSuite: + def test_canonical_fixture_exists(self): + import pathlib + + fixture = ( + pathlib.Path(__file__).parent.parent.parent + / "benchmarks" + / "fixtures" + / "canonical_ohlcv.npz" + ) + assert fixture.exists(), f"Canonical fixture not found: {fixture}" + + def test_canonical_fixture_loadable(self): + import pathlib + + fixture = ( + pathlib.Path(__file__).parent.parent.parent + / "benchmarks" + / "fixtures" + / "canonical_ohlcv.npz" + ) + if not fixture.exists(): + pytest.skip("Canonical fixture not found") + data = np.load(fixture) + for key in ["open", "high", "low", "close", "volume"]: + assert key in data.files, f"Missing key '{key}' in fixture" + assert len(data["close"]) == 2000 + + def test_benchmark_indicators_run(self): + import pathlib + + fixture = ( + pathlib.Path(__file__).parent.parent.parent + / "benchmarks" + / "fixtures" + / "canonical_ohlcv.npz" + ) + if not fixture.exists(): + pytest.skip("Canonical fixture not found") + import ferro_ta as ft + + data = np.load(fixture) + close = data["close"] + high = data["high"] + low = data["low"] + + out_sma = np.asarray(ft.SMA(close, timeperiod=20)) + out_rsi = np.asarray(ft.RSI(close, timeperiod=14)) + out_atr = np.asarray(ft.ATR(high, low, close, timeperiod=14)) + + assert len(out_sma) == len(close) + assert len(out_rsi) == len(close) + assert len(out_atr) == len(close) + # Last value should be finite + assert np.isfinite(out_sma[-1]) + assert np.isfinite(out_rsi[-1]) + assert np.isfinite(out_atr[-1]) + + +# =========================================================================== +# Options / IV helpers +# =========================================================================== + + +class TestIVRank: + def test_basic_shape(self): + from ferro_ta.analysis.options import iv_rank + + iv = _make_close(100) + result = iv_rank(iv, window=20) + assert result.shape == (100,) + + def test_warmup_nan(self): + from ferro_ta.analysis.options import iv_rank + + iv = _make_close(50) + result = iv_rank(iv, window=10) + assert np.all(np.isnan(result[:9])) + assert not np.isnan(result[9]) + + def test_values_in_0_1(self): + from ferro_ta.analysis.options import iv_rank + + iv = _make_close(100) + result = iv_rank(iv, window=20) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0.0) + assert np.all(valid <= 1.0) + + def test_max_value_is_1(self): + from ferro_ta.analysis.options import iv_rank + + # The maximum of a window should produce rank = 1 + iv = np.array([10.0, 20.0, 30.0, 40.0, 50.0]) + result = iv_rank(iv, window=5) + assert result[4] == pytest.approx(1.0) + + def test_min_value_is_0(self): + from ferro_ta.analysis.options import iv_rank + + iv = np.array([50.0, 40.0, 30.0, 20.0, 10.0]) + result = iv_rank(iv, window=5) + assert result[4] == pytest.approx(0.0) + + def test_empty_raises(self): + from ferro_ta.analysis.options import iv_rank + + with pytest.raises(Exception): + iv_rank(np.array([]), window=5) + + def test_window_1(self): + from ferro_ta.analysis.options import iv_rank + + iv = np.array([10.0, 20.0, 30.0]) + result = iv_rank(iv, window=1) + # With window=1, all values are equal to min=max, so rank=0 + assert np.all(result == 0.0) + + def test_invalid_window_raises(self): + from ferro_ta.analysis.options import iv_rank + + with pytest.raises(Exception): + iv_rank(np.array([1.0, 2.0]), window=0) + + def test_flat_series(self): + from ferro_ta.analysis.options import iv_rank + + iv = np.ones(30) * 25.0 + result = iv_rank(iv, window=10) + valid = result[~np.isnan(result)] + assert np.all(valid == 0.0) + + +class TestIVPercentile: + def test_basic_shape(self): + from ferro_ta.analysis.options import iv_percentile + + iv = _make_close(100) + result = iv_percentile(iv, window=20) + assert result.shape == (100,) + + def test_warmup_nan(self): + from ferro_ta.analysis.options import iv_percentile + + iv = _make_close(50) + result = iv_percentile(iv, window=10) + assert np.all(np.isnan(result[:9])) + + def test_values_in_0_1(self): + from ferro_ta.analysis.options import iv_percentile + + iv = _make_close(100) + result = iv_percentile(iv, window=20) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0.0) + assert np.all(valid <= 1.0) + + def test_empty_raises(self): + from ferro_ta.analysis.options import iv_percentile + + with pytest.raises(Exception): + iv_percentile(np.array([]), window=5) + + def test_known_value(self): + from ferro_ta.analysis.options import iv_percentile + + iv = np.array([10.0, 20.0, 30.0, 15.0, 22.0]) + result = iv_percentile(iv, window=3) + # At index 2: window=[10,20,30], current=30. All 3 <= 30 → 3/3 = 1.0 + assert result[2] == pytest.approx(1.0) + # At index 3: window=[20,30,15], current=15. Only 15 <= 15 → 1/3 + assert result[3] == pytest.approx(1.0 / 3.0) + + +class TestIVZScore: + def test_basic_shape(self): + from ferro_ta.analysis.options import iv_zscore + + iv = _make_close(100) + result = iv_zscore(iv, window=20) + assert result.shape == (100,) + + def test_warmup_nan(self): + from ferro_ta.analysis.options import iv_zscore + + iv = _make_close(50) + result = iv_zscore(iv, window=10) + assert np.all(np.isnan(result[:9])) + + def test_flat_is_nan(self): + from ferro_ta.analysis.options import iv_zscore + + # Flat series has std=0, so z-score should be NaN + iv = np.ones(30) * 20.0 + result = iv_zscore(iv, window=10) + valid = result[~np.isnan(result)] + assert len(valid) == 0 or np.all(np.isnan(valid)) + + def test_empty_raises(self): + from ferro_ta.analysis.options import iv_zscore + + with pytest.raises(Exception): + iv_zscore(np.array([]), window=5) + + def test_known_value(self): + from ferro_ta.analysis.options import iv_zscore + + iv = np.array([10.0, 20.0, 30.0]) + result = iv_zscore(iv, window=3) + # mean=20, std=std([10,20,30],ddof=0)=8.165... + expected = (30.0 - 20.0) / np.std([10.0, 20.0, 30.0], ddof=0) + assert result[2] == pytest.approx(expected, rel=1e-6) + + +# =========================================================================== +# Agentic tools and workflow +# =========================================================================== + + +class TestComputeIndicator: + def test_sma_basic(self): + from ferro_ta.tools import compute_indicator + + close = np.linspace(100, 110, 20) + result = compute_indicator("SMA", close, timeperiod=5) + assert isinstance(result, np.ndarray) + assert result.shape == (20,) + + def test_rsi_basic(self): + from ferro_ta.tools import compute_indicator + + close = _make_close(100) + result = compute_indicator("RSI", close, timeperiod=14) + assert isinstance(result, np.ndarray) + assert result.shape == (100,) + + def test_bbands_multi_output(self): + from ferro_ta.tools import compute_indicator + + close = _make_close(50) + result = compute_indicator("BBANDS", close, timeperiod=10) + assert isinstance(result, dict) + assert "upper" in result + assert "middle" in result + assert "lower" in result + + def test_macd_multi_output(self): + from ferro_ta.tools import compute_indicator + + close = _make_close(100) + result = compute_indicator( + "MACD", close, fastperiod=5, slowperiod=10, signalperiod=3 + ) + assert isinstance(result, dict) + assert "macd" in result + assert "signal" in result + assert "hist" in result + + def test_unknown_indicator_raises(self): + from ferro_ta.tools import compute_indicator + + with pytest.raises(Exception): + compute_indicator("NO_SUCH_INDICATOR", np.ones(20)) + + +class TestRunBacktest: + def test_basic_result_shape(self): + from ferro_ta.tools import run_backtest + + close = _make_close(200) + summary = run_backtest("rsi_30_70", close) + assert isinstance(summary, dict) + assert "final_equity" in summary + assert "n_trades" in summary + assert "n_bars" in summary + assert "equity" in summary + assert "signals" in summary + assert "max_drawdown" in summary + assert summary["n_bars"] == 200 + assert isinstance(summary["final_equity"], float) + + def test_equity_list(self): + from ferro_ta.tools import run_backtest + + close = _make_close(100) + summary = run_backtest("rsi_30_70", close) + assert isinstance(summary["equity"], list) + assert len(summary["equity"]) == 100 + + def test_sma_crossover_strategy(self): + from ferro_ta.tools import run_backtest + + close = _make_close(200) + summary = run_backtest("sma_crossover", close, fast=5, slow=20) + assert "final_equity" in summary + + def test_macd_crossover_strategy(self): + from ferro_ta.tools import run_backtest + + close = _make_close(200) + summary = run_backtest("macd_crossover", close) + assert "final_equity" in summary + + def test_unknown_strategy_raises(self): + from ferro_ta.tools import run_backtest + + with pytest.raises(Exception): + run_backtest("no_such_strategy", _make_close(100)) + + def test_max_drawdown_non_negative(self): + from ferro_ta.tools import run_backtest + + close = _make_close(200) + summary = run_backtest("rsi_30_70", close) + assert summary["max_drawdown"] >= 0.0 + + +class TestListIndicators: + def test_returns_list(self): + from ferro_ta.tools import list_indicators + + names = list_indicators() + assert isinstance(names, list) + assert len(names) > 0 + + def test_contains_sma_rsi(self): + from ferro_ta.tools import list_indicators + + names = list_indicators() + assert "SMA" in names + assert "RSI" in names + + def test_sorted(self): + from ferro_ta.tools import list_indicators + + names = list_indicators() + assert names == sorted(names) + + +class TestDescribeIndicator: + def test_returns_string(self): + from ferro_ta.tools import describe_indicator + + desc = describe_indicator("SMA") + assert isinstance(desc, str) + assert len(desc) > 0 + + def test_unknown_raises(self): + from ferro_ta.tools import describe_indicator + + with pytest.raises(Exception): + describe_indicator("NO_SUCH_INDICATOR") + + +class TestWorkflow: + def test_basic_indicators(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(200) + result = ( + Workflow() + .add_indicator("sma_20", "SMA", timeperiod=20) + .add_indicator("rsi_14", "RSI", timeperiod=14) + .run(close) + ) + assert "sma_20" in result + assert "rsi_14" in result + assert result["sma_20"].shape == (200,) + assert result["rsi_14"].shape == (200,) + + def test_with_strategy(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(200) + result = ( + Workflow() + .add_indicator("rsi_14", "RSI", timeperiod=14) + .add_strategy("rsi_30_70") + .run(close) + ) + assert "backtest" in result + assert "final_equity" in result["backtest"] + + def test_with_alert(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(200) + result = ( + Workflow() + .add_indicator("rsi_14", "RSI", timeperiod=14) + .add_alert("rsi_14", level=30.0, direction=-1) + .run(close) + ) + assert "rsi_14" in result + # Alert key should be present + alert_keys = [k for k in result if k.startswith("alert_")] + assert len(alert_keys) > 0 + + def test_empty_workflow(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(50) + result = Workflow().run(close) + assert isinstance(result, dict) + assert len(result) == 0 + + def test_multi_output_indicator(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(100) + result = Workflow().add_indicator("bb", "BBANDS", timeperiod=10).run(close) + assert "bb" in result + # BBANDS returns dict from compute_indicator + assert isinstance(result["bb"], dict) + + +class TestRunPipeline: + def test_basic_pipeline(self): + from ferro_ta.tools.workflow import run_pipeline + + close = _make_close(200) + result = run_pipeline( + close, + indicators={ + "sma_20": {"name": "SMA", "timeperiod": 20}, + "rsi_14": {"name": "RSI", "timeperiod": 14}, + }, + ) + assert "sma_20" in result + assert "rsi_14" in result + + def test_with_strategy(self): + from ferro_ta.tools.workflow import run_pipeline + + close = _make_close(200) + result = run_pipeline( + close, + indicators={"rsi_14": {"name": "RSI", "timeperiod": 14}}, + strategy="rsi_30_70", + ) + assert "backtest" in result + + def test_no_indicators(self): + from ferro_ta.tools.workflow import run_pipeline + + close = _make_close(100) + result = run_pipeline(close) + assert isinstance(result, dict) + + def test_with_alert(self): + from ferro_ta.tools.workflow import run_pipeline + + close = _make_close(200) + result = run_pipeline( + close, + indicators={"rsi_14": {"name": "RSI", "timeperiod": 14}}, + alert_indicator="rsi_14", + alert_level=30.0, + alert_direction=-1, + ) + assert "rsi_14" in result + alert_keys = [k for k in result if k.startswith("alert_")] + assert len(alert_keys) > 0 + + +# =========================================================================== +# MCP server +# =========================================================================== + + +class TestMCPListTools: + def test_list_tools_returns_dict(self): + from ferro_ta.mcp import handle_list_tools + + result = handle_list_tools() + assert isinstance(result, dict) + assert "tools" in result + + def test_list_tools_has_required_tools(self): + from ferro_ta.mcp import handle_list_tools + + result = handle_list_tools() + names = [t["name"] for t in result["tools"]] + assert len(names) > 250 + for expected in ( + "sma", + "ema", + "rsi", + "macd", + "backtest", + "SMA", + "compute_indicator", + "about", + "check_cross", + "TickAggregator", + "call_instance_method", + "call_stored_callable", + "delete_instance", + ): + assert expected in names, f"Expected tool '{expected}' not found" + + def test_each_tool_has_schema(self): + from ferro_ta.mcp import handle_list_tools + + result = handle_list_tools() + for tool in result["tools"]: + assert "name" in tool + assert "description" in tool + assert "inputSchema" in tool + + +class TestMCPCallTool: + def test_sma_call(self): + from ferro_ta.mcp import handle_call_tool + + close = list(np.linspace(100, 110, 30)) + result = handle_call_tool("sma", {"close": close, "timeperiod": 5}) + assert "content" in result + import json + + payload = json.loads(result["content"][0]["text"]) + assert len(payload) == 30 + + def test_ema_call(self): + from ferro_ta.mcp import handle_call_tool + + close = list(np.linspace(100, 110, 30)) + result = handle_call_tool("ema", {"close": close, "timeperiod": 5}) + assert "content" in result + + def test_rsi_call(self): + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(50)) + result = handle_call_tool("rsi", {"close": close, "timeperiod": 14}) + assert "content" in result + + def test_macd_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(100)) + result = handle_call_tool("macd", {"close": close}) + assert "content" in result + payload = json.loads(result["content"][0]["text"]) + assert "macd" in payload + + def test_backtest_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(200)) + result = handle_call_tool("backtest", {"close": close, "strategy": "rsi_30_70"}) + assert "content" in result + payload = json.loads(result["content"][0]["text"]) + assert "final_equity" in payload + assert "n_trades" in payload + + def test_top_level_sma_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + close = list(np.linspace(100, 110, 30)) + result = handle_call_tool("SMA", {"close": close, "timeperiod": 5}) + payload = json.loads(result["content"][0]["text"]) + assert len(payload) == 30 + + def test_compute_indicator_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(100)) + result = handle_call_tool( + "compute_indicator", + { + "name": "MACD", + "args": [close], + }, + ) + payload = json.loads(result["content"][0]["text"]) + assert "macd" in payload + + def test_about_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool("about", {}) + payload = json.loads(result["content"][0]["text"]) + assert payload["indicator_count"] >= 200 + assert payload["method_count"] >= 400 + + def test_check_cross_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool( + "check_cross", + { + "fast": [1.0, 2.0, 3.0, 2.0, 1.0], + "slow": [2.0, 2.0, 2.0, 2.0, 2.0], + }, + ) + payload = json.loads(result["content"][0]["text"]) + assert len(payload) == 5 + + def test_list_indicators_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool("list_indicators", {}) + assert "content" in result + payload = json.loads(result["content"][0]["text"]) + assert isinstance(payload, list) + assert "SMA" in payload + + def test_describe_indicator_call(self): + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool("describe_indicator", {"name": "SMA"}) + assert "content" in result + text = result["content"][0]["text"] + assert isinstance(text, str) + assert len(text) > 0 + + def test_unknown_tool_returns_error(self): + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool("no_such_tool", {}) + assert result.get("isError") is True + + def test_tool_error_handling(self): + from ferro_ta.mcp import handle_call_tool + + # Pass an invalid series to trigger an error + result = handle_call_tool("sma", {"close": [], "timeperiod": 5}) + # Should return error content, not raise + assert "content" in result or "isError" in result + + def test_backtest_unknown_strategy(self): + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(100)) + result = handle_call_tool( + "backtest", {"close": close, "strategy": "no_strategy"} + ) + assert result.get("isError") is True or "content" in result + + def test_tick_aggregator_instance_lifecycle(self): + import json + + from ferro_ta.mcp import handle_call_tool + + created = json.loads( + handle_call_tool("TickAggregator", {"rule": "tick:2"})["content"][0]["text"] + ) + instance_id = created["instance_id"] + + described = json.loads( + handle_call_tool("describe_instance", {"instance_id": instance_id})[ + "content" + ][0]["text"] + ) + method_names = [item["name"] for item in described["methods"]] + assert "aggregate" in method_names + + aggregated = json.loads( + handle_call_tool( + "call_instance_method", + { + "instance_id": instance_id, + "method": "aggregate", + "args": [ + { + "price": [1.0, 2.0, 3.0, 4.0], + "size": [1.0, 1.0, 1.0, 1.0], + } + ], + }, + )["content"][0]["text"] + ) + assert "open" in aggregated + assert "close" in aggregated + + deleted = json.loads( + handle_call_tool("delete_instance", {"instance_id": instance_id})[ + "content" + ][0]["text"] + ) + assert deleted["deleted"] is True + + def test_stored_callable_can_be_invoked(self): + import json + + from ferro_ta.mcp import handle_call_tool + + wrapped = json.loads( + handle_call_tool("traced", {"func": {"callable": "SMA"}})["content"][0][ + "text" + ] + ) + instance_id = wrapped["instance_id"] + + called = json.loads( + handle_call_tool( + "call_stored_callable", + { + "instance_id": instance_id, + "args": [[1.0, 2.0, 3.0, 4.0, 5.0]], + "kwargs": {"timeperiod": 3}, + }, + )["content"][0]["text"] + ) + assert len(called) == 5 + + handle_call_tool("delete_instance", {"instance_id": instance_id}) + + def test_benchmark_accepts_callable_reference(self): + import json + + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool( + "benchmark", + { + "func": {"callable": "SMA"}, + "args": [[1.0, 2.0, 3.0, 4.0, 5.0]], + "kwargs": {"timeperiod": 3}, + "n": 2, + "warmup": 0, + }, + ) + payload = json.loads(result["content"][0]["text"]) + assert payload["n"] == 2.0 + assert "mean_ms" in payload + + +class TestMCPServer: + def test_create_server_requires_mcp_dependency(self, monkeypatch): + import ferro_ta.mcp as mcp_mod + + real_import_module = importlib.import_module + + def fake_import_module(name, package=None): + if name.startswith("mcp"): + raise ImportError("No module named 'mcp'") + return real_import_module(name, package) + + mcp_mod.create_server.cache_clear() + monkeypatch.setattr(importlib, "import_module", fake_import_module) + + with pytest.raises(RuntimeError, match='pip install "ferro-ta\\[mcp\\]"'): + mcp_mod.create_server() + + def test_main_entrypoint_invokes_run_server(self, monkeypatch): + import ferro_ta.mcp as mcp_mod + + calls: list[str] = [] + + monkeypatch.setattr(mcp_mod, "run_server", lambda: calls.append("called")) + runpy.run_module("ferro_ta.mcp.__main__", run_name="__main__") + + assert calls == ["called"] + + def test_create_server_registers_generated_tools(self): + import ferro_ta.mcp as mcp_mod + + server = mcp_mod.create_server() + tool_names = [tool.name for tool in server._tool_manager.list_tools()] + + assert "SMA" in tool_names + assert "TickAggregator" in tool_names + assert "call_instance_method" in tool_names diff --git a/ferro-ta-main/tests/unit/test_validation.py b/ferro-ta-main/tests/unit/test_validation.py new file mode 100644 index 0000000..e19d8f8 --- /dev/null +++ b/ferro-ta-main/tests/unit/test_validation.py @@ -0,0 +1,155 @@ +"""Tests for validation and error handling.""" + +import numpy as np +import pytest + +from ferro_ta import ( + ATR, + BBANDS, + CDLDOJI, + MACD, + RSI, + SMA, + FerroTAInputError, + FerroTAValueError, +) +from ferro_ta.core.exceptions import check_min_length, check_timeperiod + +# --------------------------------------------------------------------------- +# Invalid timeperiod / period parameters → FerroTAValueError +# --------------------------------------------------------------------------- + + +class TestInvalidTimeperiod: + """Invalid period parameters must raise FerroTAValueError.""" + + def test_sma_timeperiod_zero(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0) + + def test_sma_timeperiod_negative(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + SMA(np.array([1.0, 2.0, 3.0]), timeperiod=-1) + + def test_rsi_timeperiod_zero(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + RSI(np.array([1.0, 2.0, 3.0]), timeperiod=0) + + def test_macd_fast_slow_periods(self): + close = np.array([1.0, 2.0, 3.0, 4.0, 5.0] * 10) + with pytest.raises(FerroTAValueError): + MACD(close, fastperiod=26, slowperiod=12) + + def test_bbands_timeperiod_zero(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + BBANDS(np.array([1.0, 2.0, 3.0]), timeperiod=0) + + def test_atr_timeperiod_zero(self): + h = np.array([1.0, 2.0, 3.0]) + low = np.array([0.5, 1.5, 2.5]) + c = np.array([0.8, 1.8, 2.8]) + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + ATR(h, low, c, timeperiod=0) + + +# --------------------------------------------------------------------------- +# Mismatched array lengths → FerroTAInputError +# --------------------------------------------------------------------------- + + +class TestMismatchedLengths: + """Mismatched OHLCV lengths must raise FerroTAInputError.""" + + def test_atr_mismatched_lengths(self): + h = np.array([1.0, 2.0, 3.0]) + low = np.array([0.5, 1.5]) + c = np.array([0.8, 1.8, 2.8]) + with pytest.raises(FerroTAInputError, match="same length"): + ATR(h, low, c, timeperiod=2) + + def test_cdl_pattern_mismatched_lengths(self): + open_ = np.array([1.0, 2.0, 3.0]) + high = np.array([1.1, 2.1]) + low = np.array([0.9, 1.9, 2.9]) + close = np.array([1.05, 2.05, 3.05]) + with pytest.raises(FerroTAInputError, match="same length"): + CDLDOJI(open_, high, low, close) + + +# --------------------------------------------------------------------------- +# Empty and short arrays (defined behaviour or clear exception) +# --------------------------------------------------------------------------- + + +class TestEmptyAndShortArrays: + """Empty or too-short arrays have defined behaviour or raise.""" + + def test_sma_empty_array(self): + # Empty array: _to_f64 returns shape (0,); Rust may return empty or raise. + arr = np.array([], dtype=np.float64) + result = SMA(arr, timeperiod=1) + assert result.shape == (0,) + + def test_sma_single_element_timeperiod_one(self): + arr = np.array([1.0]) + result = SMA(arr, timeperiod=1) + assert len(result) == 1 + assert result[0] == 1.0 + + def test_sma_short_array_timeperiod_larger_than_length(self): + # len=3, timeperiod=5 → output is all NaN for warmup + arr = np.array([1.0, 2.0, 3.0]) + result = SMA(arr, timeperiod=5) + assert len(result) == 3 + assert np.all(np.isnan(result)) + + def test_rsi_all_nan_input(self): + # All-NaN input: output is all NaN (propagation) + arr = np.array([np.nan, np.nan, np.nan, np.nan, np.nan]) + result = RSI(arr, timeperiod=2) + assert len(result) == 5 + assert np.all(np.isnan(result)) + + +# --------------------------------------------------------------------------- +# Validation helpers (check_timeperiod, check_min_length) +# --------------------------------------------------------------------------- + + +class TestValidationHelpers: + """Exported validation helpers behave as documented.""" + + def test_check_timeperiod_ok(self): + check_timeperiod(5) + check_timeperiod(1) + + def test_check_timeperiod_raises(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + check_timeperiod(0) + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + check_timeperiod(-1) + + def test_check_min_length_ok(self): + check_min_length(np.array([1.0, 2.0, 3.0]), 2) + check_min_length([1, 2, 3], 3) + + def test_check_min_length_raises(self): + with pytest.raises(FerroTAInputError, match="at least 3 elements"): + check_min_length(np.array([1.0, 2.0]), 3, name="input") + + +# --------------------------------------------------------------------------- +# Exception inheritance (ValueError still works) +# --------------------------------------------------------------------------- + + +class TestExceptionInheritance: + """FerroTAValueError/FerroTAInputError are ValueErrors for backward compatibility.""" + + def test_catch_value_error(self): + with pytest.raises(ValueError, match="timeperiod must be >= 1"): + SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0) + + def test_catch_ferro_ta_value_error(self): + with pytest.raises(FerroTAValueError): + SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0) diff --git a/ferro-ta-main/tests/unit/tools/__init__.py b/ferro-ta-main/tests/unit/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ferro-ta-main/uv.lock b/ferro-ta-main/uv.lock new file mode 100644 index 0000000..363a20c --- /dev/null +++ b/ferro-ta-main/uv.lock @@ -0,0 +1,4456 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +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'", + "python_full_version < '3.11'", +] + +[manifest] +constraints = [ + { name = "pygments", specifier = ">=2.20.0" }, + { name = "requests", specifier = ">=2.33.0" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054, upload-time = "2026-01-08T16:41:47.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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" +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/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.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.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/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", 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')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/fe/7351d7e586a8b4c9f89731bfe4cf0148223e8f9903ff09571f78b3fb0682/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b395f79cb89ce0cd8effff07c4a1e20101b873c256a1aeb286e8fd7bd0f556", size = 5744254, upload-time = "2026-03-11T00:12:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/184aa775e970fc089942cd9ec6302e6e44679d4c14549c6a7ea45bf7f798/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6f3682ec3c4769326aafc67c2ba669d97d688d0b7e63e659d36d2f8b72f32d6", size = 6329075, upload-time = "2026-03-11T00:12:32.319Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" }, + { url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, + { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/02/59a5bc738a09def0b49aea0e460bdf97f65206d0d041246147cf6207e69c/cuda_pathfinder-1.4.1-py3-none-any.whl", hash = "sha256:40793006082de88e0950753655e55558a446bed9a7d9d0bcb48b2506d50ed82a", size = 43903, upload-time = "2026-03-06T21:05:24.372Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +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'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, +] + +[[package]] +name = "ferro-ta" +version = "1.2.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, +] + +[package.optional-dependencies] +all = [ + { name = "pandas" }, + { name = "polars" }, + { name = "pytest" }, + { name = "pytest-benchmark" }, +] +benchmark = [ + { name = "pytest" }, + { name = "pytest-benchmark" }, +] +comparison = [ + { 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" }, + { name = "httpx" }, + { name = "hypothesis" }, + { name = "matplotlib" }, + { name = "maturin" }, + { name = "mypy" }, + { name = "pandas" }, + { name = "polars" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pyyaml" }, + { name = "ruff" }, + { 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'" }, +] +docs = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-rtd-theme" }, +] +gpu = [ + { name = "torch" }, +] +mcp = [ + { name = "mcp" }, +] +pandas = [ + { name = "pandas" }, +] +polars = [ + { name = "polars" }, +] +test = [ + { name = "hypothesis" }, + { name = "pytest" }, +] + +[package.dev-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "maturin" }, + { name = "mypy" }, + { name = "pandas" }, + { name = "pandas-ta", marker = "python_full_version >= '3.12'" }, + { name = "polars" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "ruff" }, + { 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 = "ta" }, +] + +[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" }, + { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.0" }, + { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.5" }, + { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.0,<2.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "numpy", specifier = ">=1.20" }, + { name = "pandas", marker = "extra == 'all'", specifier = ">=1.0" }, + { name = "pandas", marker = "extra == 'comparison'", specifier = ">=1.0" }, + { name = "pandas", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "pandas", marker = "extra == 'pandas'", specifier = ">=1.0" }, + { name = "pandas-ta", marker = "python_full_version >= '3.12' and extra == 'comparison'", specifier = ">=0.3" }, + { name = "polars", marker = "extra == 'all'", specifier = ">=0.19" }, + { name = "polars", marker = "extra == 'dev'", specifier = ">=0.19" }, + { name = "polars", marker = "extra == 'polars'", specifier = ">=0.19" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" }, + { name = "pytest", marker = "extra == 'all'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'benchmark'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'comparison'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { 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 = "scipy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { 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"] + +[package.metadata.requires-dev] +dev = [ + { name = "hypothesis", specifier = ">=6.0" }, + { name = "maturin", specifier = ">=1.0,<2.0" }, + { name = "mypy", specifier = ">=1.0" }, + { name = "pandas", specifier = ">=1.0" }, + { name = "pandas-ta", marker = "python_full_version >= '3.12'", specifier = ">=0.3" }, + { name = "polars", specifier = ">=0.19" }, + { name = "pre-commit", specifier = ">=3.0" }, + { name = "pyright", specifier = ">=1.1" }, + { name = "pytest", specifier = ">=7.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.3" }, + { name = "scipy", specifier = ">=1.15.3" }, + { name = "ta", specifier = ">=0.10" }, +] + +[[package]] +name = "filelock" +version = "3.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, +] + +[[package]] +name = "fonttools" +version = "4.61.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/94/8a28707adb00bed1bf22dac16ccafe60faf2ade353dcb32c3617ee917307/fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24", size = 2854799, upload-time = "2025-12-12T17:29:27.5Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/c2e682faaa5ee92034818d8f8a8145ae73eb83619600495dcf8503fa7771/fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958", size = 2403032, upload-time = "2025-12-12T17:29:30.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/62/1748f7e7e1ee41aa52279fd2e3a6d0733dc42a673b16932bad8e5d0c8b28/fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da", size = 4897863, upload-time = "2025-12-12T17:29:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/69/69/4ca02ee367d2c98edcaeb83fc278d20972502ee071214ad9d8ca85e06080/fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6", size = 4859076, upload-time = "2025-12-12T17:29:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f5/660f9e3cefa078861a7f099107c6d203b568a6227eef163dd173bfc56bdc/fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1", size = 4875623, upload-time = "2025-12-12T17:29:37.33Z" }, + { url = "https://files.pythonhosted.org/packages/63/d1/9d7c5091d2276ed47795c131c1bf9316c3c1ab2789c22e2f59e0572ccd38/fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881", size = 4993327, upload-time = "2025-12-12T17:29:39.781Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2d/28def73837885ae32260d07660a052b99f0aa00454867d33745dfe49dbf0/fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47", size = 1502180, upload-time = "2025-12-12T17:29:42.217Z" }, + { url = "https://files.pythonhosted.org/packages/63/fa/bfdc98abb4dd2bd491033e85e3ba69a2313c850e759a6daa014bc9433b0f/fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6", size = 1550654, upload-time = "2025-12-12T17:29:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, + { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, + { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, + { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, + { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, + { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, + { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, + { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, + { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, + { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, + { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, + { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, + { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, + { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/5d/8ce64e36d4e3aac5ca96996457dcf33e34e6051492399a3f1fec5657f30b/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b", size = 124159, upload-time = "2025-08-10T21:25:35.472Z" }, + { url = "https://files.pythonhosted.org/packages/96/1e/22f63ec454874378175a5f435d6ea1363dd33fb2af832c6643e4ccea0dc8/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f", size = 66578, upload-time = "2025-08-10T21:25:36.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/4c/1925dcfff47a02d465121967b95151c82d11027d5ec5242771e580e731bd/kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf", size = 65312, upload-time = "2025-08-10T21:25:37.658Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/0f333164e6307a0687d1eb9ad256215aae2f4bd5d28f4653d6cd319a3ba3/kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9", size = 1628458, upload-time = "2025-08-10T21:25:39.067Z" }, + { url = "https://files.pythonhosted.org/packages/86/b6/2dccb977d651943995a90bfe3495c2ab2ba5cd77093d9f2318a20c9a6f59/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415", size = 1225640, upload-time = "2025-08-10T21:25:40.489Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/362ebd3eec46c850ccf2bfe3e30f2fc4c008750011f38a850f088c56a1c6/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b", size = 1244074, upload-time = "2025-08-10T21:25:42.221Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bb/f09a1e66dab8984773d13184a10a29fe67125337649d26bdef547024ed6b/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154", size = 1293036, upload-time = "2025-08-10T21:25:43.801Z" }, + { url = "https://files.pythonhosted.org/packages/ea/01/11ecf892f201cafda0f68fa59212edaea93e96c37884b747c181303fccd1/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48", size = 2175310, upload-time = "2025-08-10T21:25:45.045Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5f/bfe11d5b934f500cc004314819ea92427e6e5462706a498c1d4fc052e08f/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220", size = 2270943, upload-time = "2025-08-10T21:25:46.393Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/259f786bf71f1e03e73d87e2db1a9a3bcab64d7b4fd780167123161630ad/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586", size = 2440488, upload-time = "2025-08-10T21:25:48.074Z" }, + { url = "https://files.pythonhosted.org/packages/1b/76/c989c278faf037c4d3421ec07a5c452cd3e09545d6dae7f87c15f54e4edf/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634", size = 2246787, upload-time = "2025-08-10T21:25:49.442Z" }, + { url = "https://files.pythonhosted.org/packages/a2/55/c2898d84ca440852e560ca9f2a0d28e6e931ac0849b896d77231929900e7/kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611", size = 73730, upload-time = "2025-08-10T21:25:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/e8/09/486d6ac523dd33b80b368247f238125d027964cfacb45c654841e88fb2ae/kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536", size = 65036, upload-time = "2025-08-10T21:25:52.063Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, + { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, + { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, + { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, + { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/a2/63/fde392691690f55b38d5dd7b3710f5353bf7a8e52de93a22968801ab8978/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527", size = 60183, upload-time = "2025-08-10T21:27:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/27/b1/6aad34edfdb7cced27f371866f211332bba215bfd918ad3322a58f480d8b/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771", size = 58675, upload-time = "2025-08-10T21:27:39.031Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1a/23d855a702bb35a76faed5ae2ba3de57d323f48b1f6b17ee2176c4849463/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e", size = 80277, upload-time = "2025-08-10T21:27:40.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/5239e3c2b8fb5afa1e8508f721bb77325f740ab6994d963e61b2b7abcc1e/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9", size = 77994, upload-time = "2025-08-10T21:27:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1c/5d4d468fb16f8410e596ed0eac02d2c68752aa7dc92997fe9d60a7147665/kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb", size = 73744, upload-time = "2025-08-10T21:27:42.254Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880, upload-time = "2025-01-20T11:14:41.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/75/d4863ddfd8ab5f6e70f4504cf8cc37f4e986ec6910f4ef8502bb7d3c1c71/llvmlite-0.44.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:9fbadbfba8422123bab5535b293da1cf72f9f478a65645ecd73e781f962ca614", size = 28132306, upload-time = "2025-01-20T11:12:18.634Z" }, + { url = "https://files.pythonhosted.org/packages/37/d9/6e8943e1515d2f1003e8278819ec03e4e653e2eeb71e4d00de6cfe59424e/llvmlite-0.44.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cccf8eb28f24840f2689fb1a45f9c0f7e582dd24e088dcf96e424834af11f791", size = 26201096, upload-time = "2025-01-20T11:12:24.544Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/8ffbc114def88cc698906bf5acab54ca9fdf9214fe04aed0e71731fb3688/llvmlite-0.44.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7202b678cdf904823c764ee0fe2dfe38a76981f4c1e51715b4cb5abb6cf1d9e8", size = 42361859, upload-time = "2025-01-20T11:12:31.839Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/9366b29ab050a726af13ebaae8d0dff00c3c58562261c79c635ad4f5eb71/llvmlite-0.44.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40526fb5e313d7b96bda4cbb2c85cd5374e04d80732dd36a282d72a560bb6408", size = 41184199, upload-time = "2025-01-20T11:12:40.049Z" }, + { url = "https://files.pythonhosted.org/packages/69/07/35e7c594b021ecb1938540f5bce543ddd8713cff97f71d81f021221edc1b/llvmlite-0.44.0-cp310-cp310-win_amd64.whl", hash = "sha256:41e3839150db4330e1b2716c0be3b5c4672525b4c9005e17c7597f835f351ce2", size = 30332381, upload-time = "2025-01-20T11:12:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e2/86b245397052386595ad726f9742e5223d7aea999b18c518a50e96c3aca4/llvmlite-0.44.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:eed7d5f29136bda63b6d7804c279e2b72e08c952b7c5df61f45db408e0ee52f3", size = 28132305, upload-time = "2025-01-20T11:12:53.936Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ec/506902dc6870249fbe2466d9cf66d531265d0f3a1157213c8f986250c033/llvmlite-0.44.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ace564d9fa44bb91eb6e6d8e7754977783c68e90a471ea7ce913bff30bd62427", size = 26201090, upload-time = "2025-01-20T11:12:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/99/fe/d030f1849ebb1f394bb3f7adad5e729b634fb100515594aca25c354ffc62/llvmlite-0.44.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5d22c3bfc842668168a786af4205ec8e3ad29fb1bc03fd11fd48460d0df64c1", size = 42361858, upload-time = "2025-01-20T11:13:07.623Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/ce6174664b9077fc673d172e4c888cb0b128e707e306bc33fff8c2035f0d/llvmlite-0.44.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f01a394e9c9b7b1d4e63c327b096d10f6f0ed149ef53d38a09b3749dcf8c9610", size = 41184200, upload-time = "2025-01-20T11:13:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c6/258801143975a6d09a373f2641237992496e15567b907a4d401839d671b8/llvmlite-0.44.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8489634d43c20cd0ad71330dde1d5bc7b9966937a263ff1ec1cebb90dc50955", size = 30331193, upload-time = "2025-01-20T11:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/e3c3195b92e6e492458f16d233e58a1a812aa2bfbef9bdd0fbafcec85c60/llvmlite-0.44.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:1d671a56acf725bf1b531d5ef76b86660a5ab8ef19bb6a46064a705c6ca80aad", size = 28132297, upload-time = "2025-01-20T11:13:32.57Z" }, + { url = "https://files.pythonhosted.org/packages/d6/53/373b6b8be67b9221d12b24125fd0ec56b1078b660eeae266ec388a6ac9a0/llvmlite-0.44.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f79a728e0435493611c9f405168682bb75ffd1fbe6fc360733b850c80a026db", size = 26201105, upload-time = "2025-01-20T11:13:38.744Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/8341fd3056419441286c8e26bf436923021005ece0bff5f41906476ae514/llvmlite-0.44.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0143a5ef336da14deaa8ec26c5449ad5b6a2b564df82fcef4be040b9cacfea9", size = 42361901, upload-time = "2025-01-20T11:13:46.711Z" }, + { url = "https://files.pythonhosted.org/packages/53/ad/d79349dc07b8a395a99153d7ce8b01d6fcdc9f8231355a5df55ded649b61/llvmlite-0.44.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d752f89e31b66db6f8da06df8b39f9b91e78c5feea1bf9e8c1fba1d1c24c065d", size = 41184247, upload-time = "2025-01-20T11:13:56.159Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/a9a17366af80127bd09decbe2a54d8974b6d8b274b39bf47fbaedeec6307/llvmlite-0.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:eae7e2d4ca8f88f89d315b48c6b741dcb925d6a1042da694aa16ab3dd4cbd3a1", size = 30332380, upload-time = "2025-01-20T11:14:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/89/24/4c0ca705a717514c2092b18476e7a12c74d34d875e05e4d742618ebbf449/llvmlite-0.44.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:319bddd44e5f71ae2689859b7203080716448a3cd1128fb144fe5c055219d516", size = 28132306, upload-time = "2025-01-20T11:14:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/01/cf/1dd5a60ba6aee7122ab9243fd614abcf22f36b0437cbbe1ccf1e3391461c/llvmlite-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c58867118bad04a0bb22a2e0068c693719658105e40009ffe95c7000fcde88e", size = 26201090, upload-time = "2025-01-20T11:14:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/656f5a357de7135a3777bd735cc7c9b8f23b4d37465505bd0eaf4be9befe/llvmlite-0.44.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46224058b13c96af1365290bdfebe9a6264ae62fb79b2b55693deed11657a8bf", size = 42361904, upload-time = "2025-01-20T11:14:22.949Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e1/12c5f20cb9168fb3464a34310411d5ad86e4163c8ff2d14a2b57e5cc6bac/llvmlite-0.44.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0097052c32bf721a4efc03bd109d335dfa57d9bffb3d4c24cc680711b8b4fc", size = 41184245, upload-time = "2025-01-20T11:14:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +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 = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/18/8b2eebd3ea086a5ec73d7081f95ec64918ceda1900075902fc296ea3ad55/maturin-1.12.6.tar.gz", hash = "sha256:d37be3a811a7f2ee28a0fa0964187efa50e90f21da0c6135c27787fa0b6a89db", size = 269165, upload-time = "2026-03-01T14:54:04.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/8b/9ddfde8a485489e3ebdc50ee3042ef1c854f00dfea776b951068f6ffe451/maturin-1.12.6-py3-none-linux_armv6l.whl", hash = "sha256:6892b4176992fcc143f9d1c1c874a816e9a041248eef46433db87b0f0aff4278", size = 9789847, upload-time = "2026-03-01T14:54:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e8/5f7fd3763f214a77ac0388dbcc71cc30aec5490016bd0c8e6bd729fc7b0a/maturin-1.12.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c0c742beeeef7fb93b6a81bd53e75507887e396fd1003c45117658d063812dad", size = 19023833, upload-time = "2026-03-01T14:53:46.743Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/706ff3839c8b2046436d4c2bc97596c558728264d18abc298a1ad862a4be/maturin-1.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cb41139295eed6411d3cdafc7430738094c2721f34b7eeb44f33cac516115dc", size = 9821620, upload-time = "2026-03-01T14:54:12.04Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9c/70917fb123c8dd6b595e913616c9c72d730cbf4a2b6cac8077dc02a12586/maturin-1.12.6-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:351f3af1488a7cbdcff3b6d8482c17164273ac981378a13a4a9937a49aec7d71", size = 9849107, upload-time = "2026-03-01T14:53:48.971Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/f1d6ad95c0a12fbe761a7c28a57540341f188564dbe8ad730a4d1788cd32/maturin-1.12.6-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6dbddfe4dc7ddee60bbac854870bd7cfec660acb54d015d24597d59a1c828f61", size = 10242855, upload-time = "2026-03-01T14:53:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/93/1b/2419843a4f1d2fb4747f3dc3d9c4a2881cd97a3274dd94738fcdf0835e79/maturin-1.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8fdb0f63e77ee3df0f027a120e9af78dbc31edf0eb0f263d55783c250c33b728", size = 9674972, upload-time = "2026-03-01T14:53:52.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b60ab2fc996d904b40e55bd475599dcdccd8f7ad3e649bf95e87970df466/maturin-1.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fa84b7493a2e80759cacc2e668fa5b444d55b9994e90707c42904f55d6322c1e", size = 9645755, upload-time = "2026-03-01T14:53:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/a4/96/03f2b55a8c226805115232fc23c4a4f33f0c9d39e11efab8166dc440f80d/maturin-1.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:e90dc12bc6a38e9495692a36c9e231c4d7e0c9bfde60719468ab7d8673db3c45", size = 12737612, upload-time = "2026-03-01T14:54:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c2/648667022c5b53cdccefa67c245e8a984970f3045820f00c2e23bdb2aff4/maturin-1.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06fc8d089f98623ce924c669b70911dfed30f9a29956c362945f727f9abc546b", size = 10455028, upload-time = "2026-03-01T14:54:07.349Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/5b5efe3ca0c043357ed3f8d2b2d556169fdbf1ff75e50e8e597708a359d2/maturin-1.12.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:75133e56274d43b9227fd49dca9a86e32f1fd56a7b55544910c4ce978c2bb5aa", size = 10014531, upload-time = "2026-03-01T14:53:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/68/d5/39c594c27b1a8b32a0cb95fff9ad60b888c4352d1d1c389ac1bd20dc1e16/maturin-1.12.6-py3-none-win32.whl", hash = "sha256:3f32e0a3720b81423c9d35c14e728cb1f954678124749776dc72d533ea1115e8", size = 8553012, upload-time = "2026-03-01T14:53:50.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/66/b262832a91747e04051e21f986bd01a8af81fbffafacc7d66a11e79aab5f/maturin-1.12.6-py3-none-win_amd64.whl", hash = "sha256:977290159d252db946054a0555263c59b3d0c7957135c69e690f4b1558ee9983", size = 9890470, upload-time = "2026-03-01T14:53:56.659Z" }, + { url = "https://files.pythonhosted.org/packages/e3/47/76b8ca470ddc8d7d36aa8c15f5a6aed1841806bb93a0f4ead8ee61e9a088/maturin-1.12.6-py3-none-win_arm64.whl", hash = "sha256:bae91976cdc8148038e13c881e1e844e5c63e58e026e8b9945aa2d19b3b4ae89", size = 8606158, upload-time = "2026-03-01T14:54:02.423Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +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" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.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'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numba" +version = "0.61.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/eb/ca/f470be59552ccbf9531d2d383b67ae0b9b524d435fb4a0d229fef135116e/numba-0.61.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:cf9f9fc00d6eca0c23fc840817ce9f439b9f03c8f03d6246c0e7f0cb15b7162a", size = 2775663, upload-time = "2025-04-09T02:57:34.143Z" }, + { url = "https://files.pythonhosted.org/packages/f5/13/3bdf52609c80d460a3b4acfb9fdb3817e392875c0d6270cf3fd9546f138b/numba-0.61.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea0247617edcb5dd61f6106a56255baab031acc4257bddaeddb3a1003b4ca3fd", size = 2778344, upload-time = "2025-04-09T02:57:36.609Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/bfb2805bcfbd479f04f835241ecf28519f6e3609912e3a985aed45e21370/numba-0.61.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae8c7a522c26215d5f62ebec436e3d341f7f590079245a2f1008dfd498cc1642", size = 3824054, upload-time = "2025-04-09T02:57:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/e3/27/797b2004745c92955470c73c82f0e300cf033c791f45bdecb4b33b12bdea/numba-0.61.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd1e74609855aa43661edffca37346e4e8462f6903889917e9f41db40907daa2", size = 3518531, upload-time = "2025-04-09T02:57:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c6/c2fb11e50482cb310afae87a997707f6c7d8a48967b9696271347441f650/numba-0.61.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae45830b129c6137294093b269ef0a22998ccc27bf7cf096ab8dcf7bca8946f9", size = 2831612, upload-time = "2025-04-09T02:57:41.559Z" }, + { url = "https://files.pythonhosted.org/packages/3f/97/c99d1056aed767503c228f7099dc11c402906b42a4757fec2819329abb98/numba-0.61.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:efd3db391df53aaa5cfbee189b6c910a5b471488749fd6606c3f33fc984c2ae2", size = 2775825, upload-time = "2025-04-09T02:57:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/9e/63c549f37136e892f006260c3e2613d09d5120672378191f2dc387ba65a2/numba-0.61.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49c980e4171948ffebf6b9a2520ea81feed113c1f4890747ba7f59e74be84b1b", size = 2778695, upload-time = "2025-04-09T02:57:44.968Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/8740616c8436c86c1b9a62e72cb891177d2c34c2d24ddcde4c390371bf4c/numba-0.61.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3945615cd73c2c7eba2a85ccc9c1730c21cd3958bfcf5a44302abae0fb07bb60", size = 3829227, upload-time = "2025-04-09T02:57:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/fc/06/66e99ae06507c31d15ff3ecd1f108f2f59e18b6e08662cd5f8a5853fbd18/numba-0.61.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbfdf4eca202cebade0b7d43896978e146f39398909a42941c9303f82f403a18", size = 3523422, upload-time = "2025-04-09T02:57:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a4/2b309a6a9f6d4d8cfba583401c7c2f9ff887adb5d54d8e2e130274c0973f/numba-0.61.2-cp311-cp311-win_amd64.whl", hash = "sha256:76bcec9f46259cedf888041b9886e257ae101c6268261b19fda8cfbc52bec9d1", size = 2831505, upload-time = "2025-04-09T02:57:50.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/c6b7b9c615cfa3b98c4c63f4316e3f6b3bbe2387740277006551784218cd/numba-0.61.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:34fba9406078bac7ab052efbf0d13939426c753ad72946baaa5bf9ae0ebb8dd2", size = 2776626, upload-time = "2025-04-09T02:57:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/92/4a/fe4e3c2ecad72d88f5f8cd04e7f7cff49e718398a2fac02d2947480a00ca/numba-0.61.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ddce10009bc097b080fc96876d14c051cc0c7679e99de3e0af59014dab7dfe8", size = 2779287, upload-time = "2025-04-09T02:57:53.658Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2d/e518df036feab381c23a624dac47f8445ac55686ec7f11083655eb707da3/numba-0.61.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b1bb509d01f23d70325d3a5a0e237cbc9544dd50e50588bc581ba860c213546", size = 3885928, upload-time = "2025-04-09T02:57:55.206Z" }, + { url = "https://files.pythonhosted.org/packages/10/0f/23cced68ead67b75d77cfcca3df4991d1855c897ee0ff3fe25a56ed82108/numba-0.61.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48a53a3de8f8793526cbe330f2a39fe9a6638efcbf11bd63f3d2f9757ae345cd", size = 3577115, upload-time = "2025-04-09T02:57:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/68/1d/ddb3e704c5a8fb90142bf9dc195c27db02a08a99f037395503bfbc1d14b3/numba-0.61.2-cp312-cp312-win_amd64.whl", hash = "sha256:97cf4f12c728cf77c9c1d7c23707e4d8fb4632b46275f8f3397de33e5877af18", size = 2831929, upload-time = "2025-04-09T02:57:58.45Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f3/0fe4c1b1f2569e8a18ad90c159298d862f96c3964392a20d74fc628aee44/numba-0.61.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3a10a8fc9afac40b1eac55717cece1b8b1ac0b946f5065c89e00bde646b5b154", size = 2771785, upload-time = "2025-04-09T02:57:59.96Z" }, + { url = "https://files.pythonhosted.org/packages/e9/71/91b277d712e46bd5059f8a5866862ed1116091a7cb03bd2704ba8ebe015f/numba-0.61.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d3bcada3c9afba3bed413fba45845f2fb9cd0d2b27dd58a1be90257e293d140", size = 2773289, upload-time = "2025-04-09T02:58:01.435Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e0/5ea04e7ad2c39288c0f0f9e8d47638ad70f28e275d092733b5817cf243c9/numba-0.61.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbca73ad81fa196bd53dc12e3aaf1564ae036e0c125f237c7644fe64a4928ab", size = 3893918, upload-time = "2025-04-09T02:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/17/58/064f4dcb7d7e9412f16ecf80ed753f92297e39f399c905389688cf950b81/numba-0.61.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f154aaea625fb32cfbe3b80c5456d514d416fcdf79733dd69c0df3a11348e9e", size = 3584056, upload-time = "2025-04-09T02:58:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/6d3a0f2d3989e62a18749e1e9913d5fa4910bbb3e3311a035baea6caf26d/numba-0.61.2-cp313-cp313-win_amd64.whl", hash = "sha256:59321215e2e0ac5fa928a8020ab00b8e57cda8a97384963ac0dfa4d4e6aa54e7", size = 2831846, upload-time = "2025-04-09T02:58:06.125Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.0.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.19.0.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", 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')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", 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')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", 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')" }, + { name = "nvidia-cusparse", 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')" }, + { name = "nvidia-nvjitlink", 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')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", 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')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.28.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { 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-ta" +version = "0.4.71b0" +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", 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" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polars" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +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.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +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" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +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 = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.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 = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "ta" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { 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" } + +[[package]] +name = "ta-lib" +version = "0.6.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "build" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/ec/27114f6255e6723783d4c4366810620a4347375ebf66f8aea86d9dd58ffd/ta_lib-0.6.8.tar.gz", hash = "sha256:3a9195299df9d7d2a6e9d16bebd6b706b0ea99e4b871864c4b034c2577e21a77", size = 380772, upload-time = "2025-10-20T20:49:56.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/3b/615c476a24ecccdaab4acb891aaf1766cde860a00f437f021f0e781562ce/ta_lib-0.6.8-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:71506116eac0d3e3598d6325b4b818c3a0f6acb3222b24d30ad726e8c4bf7ea8", size = 1078886, upload-time = "2025-10-20T20:48:35.739Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/04c00b6577da762238465cc755b26b0cf0a637212672354fe07d6452506e/ta_lib-0.6.8-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:b3b017d9103e7a7372a146773be32b184ff7330bd708d40b1f56f06a686756ed", size = 986216, upload-time = "2025-10-20T20:48:37.333Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/581fb525e429d07a816e7b749b103add5344fa5d8d35ddcdad55bbdfd0f5/ta_lib-0.6.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1fd18e45c39d5a4be4b0d6a20c141e43fe46daeb1b2e2f304ebae7015ab6e6", size = 3885277, upload-time = "2025-10-20T20:48:38.911Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/30b752b1be853b20a12330548d73a0039681ba731c72c77ad2b2afbb50e0/ta_lib-0.6.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c6a1e8f98de92e817491b50aa4d01d69a1b41a4ed3173747e8f16f0d4cf81cc", size = 3956167, upload-time = "2025-10-20T20:48:40.546Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0d/d1a5546fec669c528db7cc61ccbfc03b931d70c2f02184af305d780ebe5a/ta_lib-0.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7333e907bff3e3997e54f89733ffa8d619842a3e1cd962bca34bdc11944c28", size = 3515254, upload-time = "2025-10-20T20:48:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ca/36/7a6169a57117b654b569a88592cf3ecdca4d0bcaafc2d3fa476629c6cc1d/ta_lib-0.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:87c1cc1057d903b78a8257a7c5f497db6fd5284f5080392bd57b66031d7389a3", size = 3617671, upload-time = "2025-10-20T20:48:44.065Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b1/ba03d935b2f44e226c3c8e31fe64e62867d3eb7fcca44aa88b2b5dbc758d/ta_lib-0.6.8-cp310-cp310-win32.whl", hash = "sha256:7a5cc6bf60791d8274edfdfe2dd7cec3f00f656dcc92e2b0a9af06c8b18ce6a6", size = 774814, upload-time = "2025-10-20T20:48:47.979Z" }, + { url = "https://files.pythonhosted.org/packages/57/4e/588f1790c2f6e45f68faad08bb03e3af6445a07266927c261f23942bada2/ta_lib-0.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:cce8de9d48289927ed18aaa420740efd52b2cd9289da32e3799afbb3a02822e8", size = 920112, upload-time = "2025-10-20T20:48:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/99/1c/28967945b741ce060907901dc5a19fd2b4ff87b5d7481980842cd7d3b6f4/ta_lib-0.6.8-cp310-cp310-win_arm64.whl", hash = "sha256:4aa0fe08383f3e5fc7d2f8cf9b42ac778f4d53fd75bcd2799a858225954eab89", size = 760436, upload-time = "2025-10-20T20:48:46.515Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8b/f0da03cb80bd7f92a362ef86b64c6aede5485cb4b1a5ef1a669a28e974f1/ta_lib-0.6.8-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:f823d0f6b04a6797fbe253bcf91666e71a6b63c290683819650c68b2468ebe64", size = 1094199, upload-time = "2025-10-20T20:48:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/33/7a/35dcba621814e0c94f222791255bf0c90bb57db870491e375c3cc748ea50/ta_lib-0.6.8-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:30de46b55873b51be945a09edf486afcc190dc47eff9fb5d2b12c9f7e3d743da", size = 1000124, upload-time = "2025-10-20T20:48:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/52/4a/79dc81240708bd6ab5489b73b6fce52a49b90f16ca80326155c106a0cca4/ta_lib-0.6.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:490e19a45cd3cdd6dfe6b46019f7ffe1103500750b41b51996a870e7c1c5f066", size = 4040093, upload-time = "2025-10-20T20:48:52.107Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3b/49f5524798ab407abc6125047de031bfe3dac41218ad99b3932185743719/ta_lib-0.6.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a395524b0fafa10446d11e11acb4742e919523de58aac03b791f26d7a783bcf0", size = 4110325, upload-time = "2025-10-20T20:48:53.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/de/ebc78aa4b391339443932cc3ef0b4fc43e90587826bfe4526c242c602f3a/ta_lib-0.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:11a373c9308eae3bac2d56d37017f9ab63968cc074a8b95be879aae3d13133aa", size = 3668236, upload-time = "2025-10-20T20:48:55.455Z" }, + { url = "https://files.pythonhosted.org/packages/59/ba/728ddf00c372fb188a85114fdb22b4ae8edee599ba1d4e18d14bcc1b98b4/ta_lib-0.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:282e49c766b5952dd8796f77d7ed3ae412cdd88e31f845b1fbbb86ac6cb7bebf", size = 3770085, upload-time = "2025-10-20T20:48:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/b6/64/fc0c3f67af28dc46f99cf9f0996c2b4a3bfc69f8d7a97c6d80d8d8664bcc/ta_lib-0.6.8-cp311-cp311-win32.whl", hash = "sha256:0a08a29690a922ba92a6cf42902a8a93c6fbda4cfed62c3c5b0471560ef60135", size = 774941, upload-time = "2025-10-20T20:49:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/2f/83/dcd76d7253a2d159d5e58821aea917fb7b4671eb07a71d0d3c770d7077d3/ta_lib-0.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:c01809fb602e2fefc8cbfb3b603bb59d2a2eaee8708410896d48a835ba00e7c5", size = 920197, upload-time = "2025-10-20T20:48:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/5b/9b/8e997dfbd94e30c0dff29b914cae832fbf7d573933fd9faf7fcdd7247608/ta_lib-0.6.8-cp311-cp311-win_arm64.whl", hash = "sha256:a89734a7bcb2ea3b6fd600a74d6fbcdb8d3fa3f7917dbd978e039710b5509c9c", size = 760103, upload-time = "2025-10-20T20:49:00.213Z" }, + { url = "https://files.pythonhosted.org/packages/18/2b/72b8b180410f1f0e76187fe1b81d4f2df26820749245ffa2d2d2fbaa82de/ta_lib-0.6.8-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:128ec92e6a0e9ff7a38edef80e3b74f15bb2ed1c531d5d3252c8dca22677651b", size = 1072242, upload-time = "2025-10-20T20:49:03.068Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9a/0ec4bbc961c9490bf59b765e18fc2c986b5a3feeda0a8537365145f1454f/ta_lib-0.6.8-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:66a8e1c1e899d15a2f7510e43527fba22d895e7f6058d027db3e3837d88a69de", size = 984850, upload-time = "2025-10-20T20:49:04.242Z" }, + { url = "https://files.pythonhosted.org/packages/5f/50/fbb0b41bf9e771ffacf212b5ff2bacb66410bcbe162e0382ff871603dd0f/ta_lib-0.6.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5929c83bd8cb7572d1c17ffdbf0eac235bf3c4d53cde1950cf89d944eaf97525", size = 3987776, upload-time = "2025-10-20T20:49:05.743Z" }, + { url = "https://files.pythonhosted.org/packages/59/79/7513f1c39a2f6cd4f35651940f3b71a0ae9d13c04fe9235a57bdeeec621c/ta_lib-0.6.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:094677b279a59c3f01c3aca8a889fda3523fd641a3805f69a2d642121b72e55e", size = 4099228, upload-time = "2025-10-20T20:49:07.294Z" }, + { url = "https://files.pythonhosted.org/packages/06/45/0ca307d5c9306b47c40b889aeed3b23a982a661d66450232863a54904175/ta_lib-0.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e920c272cd9e70a6b10eae9203cc96845da142e1dd4482de9343dda3738a9862", size = 3611568, upload-time = "2025-10-20T20:49:09.078Z" }, + { url = "https://files.pythonhosted.org/packages/79/ca/0df51f6c065f5716371929e9d991ee16909ab3eb8b0c16dab47b05a4ece6/ta_lib-0.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d556d1c256b3700b60b6b061664a667b2e49d599c2772d46a9f2348f2dc4ab5c", size = 3729898, upload-time = "2025-10-20T20:49:10.764Z" }, + { url = "https://files.pythonhosted.org/packages/f6/df/c34a82164bb86585075dfe6a77051a1e45d8a2e57cbaabc7c74a5ed760d4/ta_lib-0.6.8-cp312-cp312-win32.whl", hash = "sha256:2b369cabb48485fbf444beb3f5a878075367b99c2c86db2f796afeabebc749e0", size = 771847, upload-time = "2025-10-20T20:49:14.828Z" }, + { url = "https://files.pythonhosted.org/packages/73/8f/adbbbd5849284c593675c0deb34ef56ce55c7e307663e7c28a598a423892/ta_lib-0.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:e781eeb65b2007af553389c8a7fb7bc53cb856118b0fcffb2c26b0f49561c686", size = 888271, upload-time = "2025-10-20T20:49:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/979d6db422a26e9417c833e49a077e12a9c1d3aa4a690dff874cde557c34/ta_lib-0.6.8-cp312-cp312-win_arm64.whl", hash = "sha256:fa7e9f2e80a9535f9692e113d02b4268b5f88675a730d1b0ef0abeb74c9a4e80", size = 753328, upload-time = "2025-10-20T20:49:13.296Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0f/0a1e6a3fff0df62d53ed4c71b5b91da6dfe2670991c94ff0a2116eb79773/ta_lib-0.6.8-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:6cf029b886cfb28a2701503b7c602b811f2daa45276bd6459b0c71e051deb497", size = 1071270, upload-time = "2025-10-20T20:49:16.223Z" }, + { url = "https://files.pythonhosted.org/packages/53/ee/036845c31209173f57f41e3a841e24c70e587fe7256e79642398154d8fb6/ta_lib-0.6.8-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ddf7453acd03b966624ebefdb38169b5bbbeea1a1a58c90b095667247f9de327", size = 985136, upload-time = "2025-10-20T20:49:17.408Z" }, + { url = "https://files.pythonhosted.org/packages/93/c8/8b6bc9f29ea361fcbb1e8fe895f9c155b51fe73b9000088f883fa5ac9b56/ta_lib-0.6.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c32fc0f546ceecc47dd45f33d72ab4a1e341b80d9081c2d77b100add5d49104", size = 3968073, upload-time = "2025-10-20T20:49:19.177Z" }, + { url = "https://files.pythonhosted.org/packages/47/4b/a46be776d1fc45d232c959aab9458182e937cc66829b820077dfd3950530/ta_lib-0.6.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bf714333788bf5175f2512b86d2ed129e89ae6f6c2923e8a297a1e3395e13b5", size = 4065499, upload-time = "2025-10-20T20:49:20.745Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b3/0f8edd802d5026a99f6bd6a7307b017a714f45e3de04f94e9b7d76665e89/ta_lib-0.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b3845e4c2fa32963fb7f384ebbaa2761b0e6b96145239bf80e956d4aff4b071c", size = 3597383, upload-time = "2025-10-20T20:49:22.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/58/078ccdee5286015dfa1864b6469f639ce6b613abbea17b167bb802a4a8b3/ta_lib-0.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4795e93d130c9b7fb661f0cead49752ae6a980437df74b99d5918026c212443e", size = 3687745, upload-time = "2025-10-20T20:49:24.127Z" }, + { url = "https://files.pythonhosted.org/packages/69/b5/8d50404307dd429aeb6d222dfbf02dca2f60e937f13e81a491a681db1a63/ta_lib-0.6.8-cp313-cp313-win32.whl", hash = "sha256:691a62926ba09f2653ec0908554b3635497efb7751c5d46b916cd1ebbb1d3c25", size = 771319, upload-time = "2025-10-20T20:49:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/1b/90/b0bdf9f3e1e88ea4052f4cc1476c86b40f6dbe3f3d201e310e93471a593b/ta_lib-0.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:34e3b12407ddf99f6627435aa8a165f094339bb7dc33de92e1d7472e9f237304", size = 887583, upload-time = "2025-10-20T20:49:25.414Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fe/03d58ea7997d9bef0e1b10e3cf160c016dd890b66413c292051e9c9b257a/ta_lib-0.6.8-cp313-cp313-win_arm64.whl", hash = "sha256:0ccd478ff5735831bf2a61d653466bfda8afadc26ad58ca6b1edb9e7521cc674", size = 753631, upload-time = "2025-10-20T20:49:26.615Z" }, + { url = "https://files.pythonhosted.org/packages/db/61/c47098dfb28c468d29fccfbb2ba35a10001d37dd51c4200a4e50c788ede6/ta_lib-0.6.8-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:36b2a516fce57309840f5ef3fa2fd0c4449293fc72536a0400d2e1e26b414da8", size = 1075848, upload-time = "2025-10-20T20:49:29.517Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e9/a30e770902c1df915a94a43e652f432e7647b710c0e1120751c05805d4bc/ta_lib-0.6.8-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7993164e8e9f78ec31d38c47850ca6ba5451788b5b49a8a2dbb3322b36b5693b", size = 986649, upload-time = "2025-10-20T20:49:30.702Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2f/8961a9e7434a2d10b8f625bb4d5c049484a898e76e9c5e40398da410aec0/ta_lib-0.6.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:613cf06313331f49dd7b85a5a24fbddb1156c9723b6921a231906241726e5aee", size = 3971825, upload-time = "2025-10-20T20:49:32.185Z" }, + { url = "https://files.pythonhosted.org/packages/75/c1/352bc32394549ac9886829a24070a507a30abf45265135b60ee77354f7da/ta_lib-0.6.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce2bc1ea01200b6d8130ab917296d05d77a1a571ec6c1ee25cfca6d55cd5db4a", size = 3991433, upload-time = "2025-10-20T20:49:34.182Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b3/7bde1867df3bf015f48d510d2ba7491359ce13c79ecf5127acae3d308272/ta_lib-0.6.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a63a52221f8c73f82f4e00493351d987f594931198589287aee96f8da673cfd5", size = 3585925, upload-time = "2025-10-20T20:49:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/8d389f60bb085b6991764d7535f066dd6009fc4f5a45dbd26dc9eaaa3c0a/ta_lib-0.6.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559326d8f3d904cd4aa61f6a392d5626f35eec6a9f6cc83bcddb0abf88c40516", size = 3629696, upload-time = "2025-10-20T20:49:37.299Z" }, + { url = "https://files.pythonhosted.org/packages/82/bc/d2e4c2b752baaee592095feb69514764b004fe53af7cc893ba9c3854cc30/ta_lib-0.6.8-cp314-cp314-win32.whl", hash = "sha256:f5b6174bf4bf9152e368561dff410203c6921e4dd2afbcda3283a95957158112", size = 766352, upload-time = "2025-10-20T20:49:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/40/98/0f2755b5bde81d7b1eaf96b4204f18fabea38b0efc869cb0ea05d57e0afc/ta_lib-0.6.8-cp314-cp314-win_amd64.whl", hash = "sha256:1fb4028437201e19014e4e374272b739867c8a3eb655da46675ef4c2ff14b616", size = 886955, upload-time = "2025-10-20T20:49:38.513Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "torch" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f2/c1690994afe461aae2d0cac62251e6802a703dec0a6c549c02ecd0de92a9/torch-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2c0d7fcfbc0c4e8bb5ebc3907cbc0c6a0da1b8f82b1fc6e14e914fa0b9baf74e", size = 80526521, upload-time = "2026-03-23T18:12:06.86Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f0/98ae802fa8c09d3149b0c8690741f3f5753c90e779bd28c9613257295945/torch-2.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:4cf8687f4aec3900f748d553483ef40e0ac38411c3c48d0a86a438f6d7a99b18", size = 419723025, upload-time = "2026-03-23T18:11:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/18a9b10b4bd34f12d4e561c52b0ae7158707b8193c6cfc0aad2b48167090/torch-2.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1b32ceda909818a03b112006709b02be1877240c31750a8d9c6b7bf5f2d8a6e5", size = 530589207, upload-time = "2026-03-23T18:11:23.756Z" }, + { url = "https://files.pythonhosted.org/packages/35/40/2d532e8c0e23705be9d1debce5bc37b68d59a39bda7584c26fe9668076fe/torch-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3c712ae6fb8e7a949051a953fc412fe0a6940337336c3b6f905e905dac5157f", size = 114518313, upload-time = "2026-03-23T18:11:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0d/98b410492609e34a155fa8b121b55c7dca229f39636851c3a9ec20edea21/torch-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b6a60d48062809f58595509c524b88e6ddec3ebe25833d6462eeab81e5f2ce4", size = 80529712, upload-time = "2026-03-23T18:12:02.608Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/acea680005f098f79fd70c1d9d5ccc0cb4296ec2af539a0450108232fc0c/torch-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d91aac77f24082809d2c5a93f52a5f085032740a1ebc9252a7b052ef5a4fddc6", size = 419718178, upload-time = "2026-03-23T18:10:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8b/d7be22fbec9ffee6cff31a39f8750d4b3a65d349a286cf4aec74c2375662/torch-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7aa2f9bbc6d4595ba72138026b2074be1233186150e9292865e04b7a63b8c67a", size = 530604548, upload-time = "2026-03-23T18:10:03.569Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bd/9912d30b68845256aabbb4a40aeefeef3c3b20db5211ccda653544ada4b6/torch-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:73e24aaf8f36ab90d95cd1761208b2eb70841c2a9ca1a3f9061b39fc5331b708", size = 114519675, upload-time = "2026-03-23T18:11:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/42e5915ebe4868caa6bac83a8ed59db57f12e9a61b7d749d584776ed53d5/torch-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f99924682ef0aa6a4ab3b1b76f40dc6e273fca09f367d15a524266db100a723f", size = 419731115, upload-time = "2026-03-23T18:11:06.944Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/82638ef24d7877510f83baf821f5619a61b45568ce21c0a87a91576510aa/torch-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f68f4ac6d95d12e896c3b7a912b5871619542ec54d3649cf48cc1edd4dd2756", size = 530712279, upload-time = "2026-03-23T18:10:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ff/6756f1c7ee302f6d202120e0f4f05b432b839908f9071157302cedfc5232/torch-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbf39280699d1b869f55eac536deceaa1b60bd6788ba74f399cc67e60a5fab10", size = 114556047, upload-time = "2026-03-23T18:10:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/87/89/5ea6722763acee56b045435fb84258db7375c48165ec8be7880ab2b281c5/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6debd97ccd3205bbb37eb806a9d8219e1139d15419982c09e23ef7d4369d18", size = 80606801, upload-time = "2026-03-23T18:10:18.649Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/8ed2173589cbfe744ed54e5a73efc107c0085ba5777ee93a5f4c1ab90553/torch-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:63a68fa59de8f87acc7e85a5478bb2dddbb3392b7593ec3e78827c793c4b73fd", size = 419732382, upload-time = "2026-03-23T18:08:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e1/b73f7c575a4b8f87a5928f50a1e35416b5e27295d8be9397d5293e7e8d4c/torch-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cc89b9b173d9adfab59fd227f0ab5e5516d9a52b658ae41d64e59d2e55a418db", size = 530711509, upload-time = "2026-03-23T18:08:47.213Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/3e3fcdd388fbe54e29fd3f991f36846ff4ac90b0d0181e9c8f7236565f82/torch-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4dda3b3f52d121063a731ddb835f010dc137b920d7fec2778e52f60d8e4bf0cd", size = 114555842, upload-time = "2026-03-23T18:09:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/db/38/8ac78069621b8c2b4979c2f96dc8409ef5e9c4189f6aac629189a78677ca/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8b394322f49af4362d4f80e424bcaca7efcd049619af03a4cf4501520bdf0fb4", size = 80959574, upload-time = "2026-03-23T18:10:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/56bfb37073e7136e6dd86bfc6af7339946dd684e0ecf2155ac0eee687ae1/torch-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2658f34ce7e2dabf4ec73b45e2ca68aedad7a5be87ea756ad656eaf32bf1e1ea", size = 419732324, upload-time = "2026-03-23T18:09:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/1b666b6d61d3394cca306ea543ed03a64aad0a201b6cd159f1d41010aeb1/torch-2.11.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:98bb213c3084cfe176302949bdc360074b18a9da7ab59ef2edc9d9f742504778", size = 530596026, upload-time = "2026-03-23T18:09:20.842Z" }, + { url = "https://files.pythonhosted.org/packages/48/6b/30d1459fa7e4b67e9e3fe1685ca1d8bb4ce7c62ef436c3a615963c6c866c/torch-2.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a97b94bbf62992949b4730c6cd2cc9aee7b335921ee8dc207d930f2ed09ae2db", size = 114793702, upload-time = "2026-03-23T18:09:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/26/0d/8603382f61abd0db35841148ddc1ffd607bf3100b11c6e1dab6d2fc44e72/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01018087326984a33b64e04c8cb5c2795f9120e0d775ada1f6638840227b04d7", size = 80573442, upload-time = "2026-03-23T18:09:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/c7/86/7cd7c66cb9cec6be330fff36db5bd0eef386d80c031b581ec81be1d4b26c/torch-2.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2bb3cc54bd0dea126b0060bb1ec9de0f9c7f7342d93d436646516b0330cd5be7", size = 419749385, upload-time = "2026-03-23T18:07:33.77Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4dc8b3809469b6c30b411bb8c4cad3828efd26236153d9beb6a3ec500f211a60", size = 530716756, upload-time = "2026-03-23T18:07:50.02Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/d4a4cda8362f8a30d1ed428564878c3cafb0d87971fbd3947d4c84552095/torch-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b4e811728bd0cc58fb2b0948fe939a1ee2bf1422f6025be2fca4c7bd9d79718", size = 114552300, upload-time = "2026-03-23T18:09:05.617Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/4419098ed6d801750f26567b478fc185c3432e11e2cad712bc6b4c2ab0d0/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8245477871c3700d4370352ffec94b103cfcb737229445cf9946cddb7b2ca7cd", size = 80959460, upload-time = "2026-03-23T18:09:00.818Z" }, + { url = "https://files.pythonhosted.org/packages/fd/66/54a56a4a6ceaffb567231994a9745821d3af922a854ed33b0b3a278e0a99/torch-2.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ab9a8482f475f9ba20e12db84b0e55e2f58784bdca43a854a6ccd3fd4b9f75e6", size = 419735835, upload-time = "2026-03-23T18:07:18.974Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e7/0b6665f533aa9e337662dc190425abc0af1fe3234088f4454c52393ded61/torch-2.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:563ed3d25542d7e7bbc5b235ccfacfeb97fb470c7fee257eae599adb8005c8a2", size = 530613405, upload-time = "2026-03-23T18:08:07.014Z" }, + { 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 = "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" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/ba/b1b04f4b291a3205d95ebd24465de0e5bf010a2df27a4e58a9b5f039d8f2/triton-3.6.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c723cfb12f6842a0ae94ac307dba7e7a44741d720a40cf0e270ed4a4e3be781", size = 175972180, upload-time = "2026-01-20T16:15:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f7/f1c9d3424ab199ac53c2da567b859bcddbb9c9e7154805119f8bd95ec36f/triton-3.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6550fae429e0667e397e5de64b332d1e5695b73650ee75a6146e2e902770bea", size = 188105201, upload-time = "2026-01-20T16:00:29.272Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2c/96f92f3c60387e14cc45aed49487f3486f89ea27106c1b1376913c62abe4/triton-3.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49df5ef37379c0c2b5c0012286f80174fcf0e073e5ade1ca9a86c36814553651", size = 176081190, upload-time = "2026-01-20T16:16:00.523Z" }, + { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" }, + { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +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.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/ferro-ta-main/wasm/Cargo.lock b/ferro-ta-main/wasm/Cargo.lock new file mode 100644 index 0000000..07d562e --- /dev/null +++ b/ferro-ta-main/wasm/Cargo.lock @@ -0,0 +1,420 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ferro_ta_core" +version = "1.2.0" + +[[package]] +name = "ferro_ta_wasm" +version = "1.2.0" +dependencies = [ + "ferro_ta_core", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e" + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/ferro-ta-main/wasm/Cargo.toml b/ferro-ta-main/wasm/Cargo.toml new file mode 100644 index 0000000..128921e --- /dev/null +++ b/ferro-ta-main/wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ferro_ta_wasm" +version = "1.2.0" +edition = "2021" +description = "WebAssembly bindings for ferro-ta technical analysis indicators" +license = "MIT" + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +wasm-bindgen = "0.2" +js-sys = "0.3" +ferro_ta_core = { path = "../crates/ferro_ta_core", default-features = false } + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[profile.release] +opt-level = "s" # optimise for size in WASM +lto = true diff --git a/ferro-ta-main/wasm/README.md b/ferro-ta-main/wasm/README.md new file mode 100644 index 0000000..a78c0a7 --- /dev/null +++ b/ferro-ta-main/wasm/README.md @@ -0,0 +1,125 @@ +# ferro-ta WASM + +WebAssembly bindings for the [ferro-ta](https://github.com/pratikbhadane24/ferro-ta) technical analysis library. Full feature parity with the Python and Rust core packages. + +## Install from npm + +```bash +npm install ferro-ta-wasm +``` + +```javascript +const ferro = require('ferro-ta-wasm'); + +const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]); +console.log('SMA:', Array.from(ferro.sma(close, 3))); +console.log('RSI:', Array.from(ferro.rsi(close, 14))); +``` + +## Available Indicators (200+ exports) + +| Category | Functions | Examples | +|----------|-----------|----------| +| Overlap Studies (20) | Moving averages, bands, SAR | `sma`, `ema`, `wma`, `dema`, `tema`, `trima`, `kama`, `t3`, `bbands`, `macd`, `macdfix`, `macdext`, `sar`, `sarext`, `mama`, `midpoint`, `midprice`, `ma`, `mavp`, `hull_ma` | +| Momentum (26) | Oscillators, directional movement | `rsi`, `mom`, `stoch`, `stochf`, `adx`, `adxr`, `dx`, `plus_di`, `minus_di`, `roc`, `willr`, `aroon`, `aroonosc`, `cci`, `bop`, `stochrsi`, `apo`, `ppo`, `cmo`, `trix_indicator`, `ultosc` | +| Candlestick Patterns (61) | All TA-Lib patterns | `cdlhammer`, `cdlengulfing`, `cdldoji`, `cdlmorningstar`, `cdlshootingstar`, ... (all 61) | +| Volatility (3) | True range, ATR | `atr`, `natr`, `trange` | +| Volume (6) | On-balance volume, accumulation | `obv`, `mfi`, `vwap`, `vwma`, `ad`, `adosc` | +| Price Transforms (4) | Synthetic prices | `avgprice`, `medprice`, `typprice`, `wclprice` | +| Cycle / Hilbert (6) | Hilbert Transform suite | `ht_trendline`, `ht_dcperiod`, `ht_dcphase`, `ht_phasor`, `ht_sine`, `ht_trendmode` | +| Statistics (10) | Regression, correlation | `stddev`, `var`, `linearreg`, `linearreg_slope`, `linearreg_intercept`, `linearreg_angle`, `tsf`, `beta_rolling`, `correl` | +| Math (19) | Operators and transforms | `math_add`, `math_sub`, `math_mult`, `math_div`, `transform_sin`, `transform_cos`, `transform_exp`, `transform_sqrt`, ... | +| Extended (10) | Supertrend, channels, Ichimoku | `supertrend`, `donchian`, `keltner_channels`, `ichimoku`, `pivot_points`, `chandelier_exit`, `choppiness_index` | +| Streaming API (9 classes) | Bar-by-bar stateful | `WasmStreamingSMA`, `WasmStreamingEMA`, `WasmStreamingRSI`, `WasmStreamingATR`, `WasmStreamingBBands`, `WasmStreamingMACD`, `WasmStreamingStoch`, `WasmStreamingVWAP`, `WasmStreamingSupertrend` | +| Options (14) | Pricing, Greeks, IV | `black_scholes_price`, `black_76_price`, `black_scholes_greeks`, `implied_volatility`, `iv_rank`, `smile_metrics`, ... | +| Futures (12) | Basis, roll, curve | `futures_basis`, `annualized_basis`, `roll_yield`, `weighted_continuous`, `calendar_spreads`, `curve_summary`, ... | +| Backtesting (9) | Signal generation, engines | `backtest_core`, `backtest_ohlcv`, `rsi_threshold_signals`, `macd_crossover_signals`, `walk_forward_indices`, `monte_carlo_bootstrap`, ... | +| Alerts & Regime (7) | Signals and regime detection | `check_threshold`, `check_cross`, `regime_adx`, `regime_combined`, `detect_breaks_cusum` | +| Batch & Portfolio (9) | Multi-asset analytics | `batch_sma`, `batch_ema`, `batch_rsi`, `correlation_matrix`, `portfolio_volatility`, `drawdown_series` | +| Aggregation (8) | Tick/volume/time bars | `aggregate_tick_bars`, `aggregate_volume_bars_ticks`, `volume_bars`, `ohlcv_agg` | + +## Prerequisites + +```bash +# Install Rust (if not already present) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Install wasm-pack +curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh +``` + +## Build + +```bash +cd wasm/ + +# Build both Node.js and web targets +npm run build + +# Or build individually: +npm run build:node # → node/ +npm run build:web # → web/ +``` + +This produces two directories: +- `node/` -- CommonJS glue for Node.js (`require()`) +- `web/` -- ESM glue for browsers and web workers (`import`) + +Both contain `ferro_ta_wasm.js`, `ferro_ta_wasm_bg.wasm`, and `ferro_ta_wasm.d.ts`. + +## Usage (Node.js) + +```javascript +const { + sma, ema, rsi, bbands, macd, atr, adx, obv, mfi, + cdlhammer, cdlengulfing, + WasmStreamingSMA, +} = require('ferro-ta-wasm'); + +const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]); +const high = new Float64Array([45.0, 46.0, 47.0, 46.0, 45.0, 44.0, 45.0]); +const low = new Float64Array([43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]); + +// Indicators +console.log('SMA:', Array.from(sma(close, 3))); +console.log('RSI:', Array.from(rsi(close, 5))); + +// Multi-output +const [upper, middle, lower] = bbands(close, 5, 2.0, 2.0); +const [macdLine, signal, hist] = macd(close, 3, 5, 2); + +// Streaming (bar-by-bar) +const stream = new WasmStreamingSMA(3); +for (const price of close) { + console.log('streaming SMA:', stream.update(price)); +} +``` + +## Usage (Browser) + +```html + +``` + +## Run Tests + +```bash +cd wasm/ +wasm-pack test --node +``` + +## Limitations + +- Large arrays (> 10M bars) may be slow due to JS-WASM memory copies. For high-throughput use cases prefer the Python (PyO3) binding. +- WASM does not support multi-threading natively in browsers (SharedArrayBuffer requires COOP/COEP headers). +- The npm package ships both Node.js (`require`) and browser/web worker (`import`) builds. Conditional exports in `package.json` select the right one automatically. + +## License + +MIT diff --git a/ferro-ta-main/wasm/bench.js b/ferro-ta-main/wasm/bench.js new file mode 100644 index 0000000..2fd0995 --- /dev/null +++ b/ferro-ta-main/wasm/bench.js @@ -0,0 +1,118 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { performance } = require("node:perf_hooks"); + +const wasm = require("./node/ferro_ta_wasm.js"); + +function parseArgs(argv) { + const args = { bars: 100000, json: null }; + for (let idx = 0; idx < argv.length; idx += 1) { + const token = argv[idx]; + if (token === "--bars") { + args.bars = Number(argv[idx + 1]); + idx += 1; + } else if (token === "--json") { + args.json = argv[idx + 1]; + idx += 1; + } + } + return args; +} + +function makeSeries(length) { + const close = new Float64Array(length); + const high = new Float64Array(length); + const low = new Float64Array(length); + const volume = new Float64Array(length); + let value = 100.0; + for (let idx = 0; idx < length; idx += 1) { + value += Math.sin(idx / 13.0) * 0.35 + Math.cos(idx / 29.0) * 0.18; + close[idx] = value; + high[idx] = value + 1.25; + low[idx] = value - 1.10; + volume[idx] = 1000.0 + Math.abs(Math.sin(idx / 7.0) * 300.0) + (idx % 100); + } + return { close, high, low, volume }; +} + +function timeMin(fn, rounds = 7) { + fn(); + let best = Number.POSITIVE_INFINITY; + for (let round = 0; round < rounds; round += 1) { + const started = performance.now(); + fn(); + best = Math.min(best, performance.now() - started); + } + return best; +} + +function runBenchmark({ bars }) { + const { close, high, low, volume } = makeSeries(bars); + const cases = [ + ["SMA", () => wasm.sma(close, 20)], + ["EMA", () => wasm.ema(close, 20)], + ["WMA", () => wasm.wma(close, 20)], + ["RSI", () => wasm.rsi(close, 14)], + ["ADX", () => wasm.adx(high, low, close, 14)], + ["MFI", () => wasm.mfi(high, low, close, volume, 14)], + ["ATR", () => wasm.atr(high, low, close, 14)], + ["BBANDS", () => wasm.bbands(close, 20, 2.0, 2.0)], + ]; + + const results = cases.map(([name, fn]) => { + const elapsedMs = timeMin(fn); + return { + indicator: name, + elapsed_ms: Number(elapsedMs.toFixed(4)), + ns_per_bar: Number(((elapsedMs * 1e6) / bars).toFixed(2)), + million_bars_per_second: Number((((bars / 1e6) / (elapsedMs / 1000))).toFixed(2)), + }; + }); + + return { + metadata: { + suite: "wasm", + runtime: { + generated_at_utc: new Date().toISOString(), + node_version: process.version, + platform: process.platform, + arch: process.arch, + }, + dataset: { + bars, + }, + }, + results, + }; +} + +function printResults(payload) { + const bars = payload.metadata.dataset.bars; + console.log(`WASM Benchmark: ${bars} bars`); + console.log("----------------------------------------------------------------"); + console.log( + `${"Indicator".padEnd(12)}${"Elapsed (ms)".padStart(14)}${"ns/bar".padStart(12)}${"M bars/s".padStart(12)}` + ); + console.log("----------------------------------------------------------------"); + for (const row of payload.results) { + console.log( + `${row.indicator.padEnd(12)}${row.elapsed_ms.toFixed(2).padStart(14)}${row.ns_per_bar + .toFixed(2) + .padStart(12)}${row.million_bars_per_second.toFixed(2).padStart(12)}` + ); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const payload = runBenchmark(args); + printResults(payload); + + if (args.json) { + const outputPath = path.resolve(args.json); + fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + console.log(`\nWrote JSON results to ${outputPath}`); + } +} + +main(); diff --git a/ferro-ta-main/wasm/package.json b/ferro-ta-main/wasm/package.json new file mode 100644 index 0000000..717da6e --- /dev/null +++ b/ferro-ta-main/wasm/package.json @@ -0,0 +1,41 @@ +{ + "name": "ferro-ta-wasm", + "version": "1.2.0", + "description": "WebAssembly bindings for ferro-ta technical analysis indicators", + "main": "node/ferro_ta_wasm.js", + "module": "web/ferro_ta_wasm.js", + "types": "node/ferro_ta_wasm.d.ts", + "exports": { + ".": { + "node": { + "types": "./node/ferro_ta_wasm.d.ts", + "require": "./node/ferro_ta_wasm.js" + }, + "default": { + "types": "./web/ferro_ta_wasm.d.ts", + "import": "./web/ferro_ta_wasm.js" + } + } + }, + "files": ["node", "web"], + "scripts": { + "build": "npm run build:node && npm run build:web", + "build:node": "wasm-pack build --target nodejs --out-dir node && node -e \"require('fs').rmSync('node/.gitignore', { force: true }); require('fs').rmSync('node/package.json', { force: true });\"", + "build:web": "wasm-pack build --target web --out-dir web && node -e \"require('fs').rmSync('web/.gitignore', { force: true }); require('fs').rmSync('web/package.json', { force: true });\"", + "bench": "node bench.js", + "prepack": "npm run build", + "test": "wasm-pack test --node" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/pratikbhadane24/ferro-ta.git", + "directory": "wasm" + }, + "homepage": "https://github.com/pratikbhadane24/ferro-ta#readme", + "bugs": "https://github.com/pratikbhadane24/ferro-ta/issues", + "publishConfig": { + "access": "public" + }, + "devDependencies": {} +} diff --git a/ferro-ta-main/wasm/src/lib.rs b/ferro-ta-main/wasm/src/lib.rs new file mode 100644 index 0000000..c2ebfa9 --- /dev/null +++ b/ferro-ta-main/wasm/src/lib.rs @@ -0,0 +1,3607 @@ +/*! +# ferro-ta WASM bindings + +WebAssembly bindings for the ferro-ta technical analysis library. + +All functions accept `Float64Array` inputs and return `Float64Array` (or a +`js_sys::Array` of `Float64Array` for multi-output indicators such as `BBANDS` +and `MACD`). + +## Overlap Studies +- [`sma`] — Simple Moving Average +- [`ema`] — Exponential Moving Average +- [`wma`] — Weighted Moving Average +- [`bbands`] — Bollinger Bands (returns `[upper, middle, lower]`) + +## Momentum Indicators +- [`rsi`] — Relative Strength Index (Wilder smoothing) +- [`macd`] — Moving Average Convergence/Divergence (returns `[macd, signal, hist]`) +- [`mom`] — Momentum (close[i] - close[i-period]) +- [`stochf`] — Fast Stochastic (returns `[fastk, fastd]`) +- [`adx`] — Average Directional Movement Index + +## Volatility Indicators +- [`atr`] — Average True Range (Wilder smoothing) + +## Volume Indicators +- [`obv`] — On-Balance Volume +- [`mfi`] — Money Flow Index +*/ + +use js_sys::{Array, Float64Array}; +use wasm_bindgen::prelude::*; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Copy a `Float64Array` into a `Vec`. +fn to_vec(arr: &Float64Array) -> Vec { + let n = arr.length() as usize; + let mut v = vec![0.0f64; n]; + arr.copy_to(&mut v); + v +} + +/// Create a `Float64Array` from a `Vec`. +fn from_vec(v: Vec) -> Float64Array { + // Safety: Float64Array::view requires the backing Vec to stay alive for the + // duration of the copy. We immediately copy via `Float64Array::from` so + // there is no aliasing. + let arr = Float64Array::new_with_length(v.len() as u32); + arr.copy_from(&v); + arr +} + +// --------------------------------------------------------------------------- +// SMA — Simple Moving Average +// --------------------------------------------------------------------------- + +/// Simple Moving Average. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back window (default 30, minimum 1). +/// +/// # Returns +/// `Float64Array` with the first `timeperiod - 1` values set to `NaN`. +#[wasm_bindgen] +pub fn sma(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::overlap::sma(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// EMA — Exponential Moving Average +// --------------------------------------------------------------------------- + +/// Exponential Moving Average (SMA-seeded). +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back period (default 30, minimum 1). +/// +/// # Returns +/// `Float64Array` with the first `timeperiod - 1` values set to `NaN`. +#[wasm_bindgen] +pub fn ema(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::overlap::ema(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// BBANDS — Bollinger Bands +// --------------------------------------------------------------------------- + +/// Bollinger Bands (SMA ± k × rolling standard deviation). +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back window (default 5, minimum 1). +/// - `nbdevup` – multiplier for the upper band (default 2.0). +/// - `nbdevdn` – multiplier for the lower band (default 2.0). +/// +/// # Returns +/// A `js_sys::Array` containing three `Float64Array` elements: +/// `[upperband, middleband, lowerband]`. +#[wasm_bindgen] +pub fn bbands( + close: &Float64Array, + timeperiod: usize, + nbdevup: f64, + nbdevdn: f64, +) -> Array { + let prices = to_vec(close); + 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)); + out.push(&from_vec(lower)); + out +} + +// --------------------------------------------------------------------------- +// RSI — Relative Strength Index (Wilder smoothing, TA-Lib compatible) +// --------------------------------------------------------------------------- + +/// Relative Strength Index (Wilder smoothing). +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back period (default 14, minimum 1). +/// +/// # Returns +/// `Float64Array` — values in `[0, 100]`; first `timeperiod` values are `NaN`. +#[wasm_bindgen] +pub fn rsi(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::momentum::rsi(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// ATR — Average True Range (Wilder smoothing) +// --------------------------------------------------------------------------- + +/// Average True Range (Wilder smoothing, TA-Lib compatible). +/// +/// # Arguments +/// - `high` – `Float64Array` of high prices. +/// - `low` – `Float64Array` of low prices. +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back period (default 14, minimum 1). +/// +/// # Returns +/// `Float64Array`; first `timeperiod` values are `NaN`. +#[wasm_bindgen] +pub fn atr( + 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::volatility::atr(&h, &l, &c, timeperiod)) +} + +// --------------------------------------------------------------------------- +// OBV — On-Balance Volume +// --------------------------------------------------------------------------- + +/// On-Balance Volume. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `volume` – `Float64Array` of volume values. +/// +/// # Returns +/// `Float64Array` — cumulative OBV. +#[wasm_bindgen] +pub fn obv(close: &Float64Array, volume: &Float64Array) -> Float64Array { + let c = to_vec(close); + let v = to_vec(volume); + from_vec(ferro_ta_core::volume::obv(&c, &v)) +} + +// --------------------------------------------------------------------------- +// WMA — Weighted Moving Average +// --------------------------------------------------------------------------- + +/// Weighted Moving Average. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back window (default 30, minimum 1). +/// +/// # Returns +/// `Float64Array` with the first `timeperiod - 1` values set to `NaN`. +#[wasm_bindgen] +pub fn wma(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::overlap::wma(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// MOM — Momentum +// --------------------------------------------------------------------------- + +/// Momentum — difference between current close and close *timeperiod* bars ago. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back window (default 10, minimum 1). +/// +/// # Returns +/// `Float64Array`; first `timeperiod` values are `NaN`. +#[wasm_bindgen] +pub fn mom(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::momentum::mom(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// STOCHF — Fast Stochastic Oscillator +// --------------------------------------------------------------------------- + +/// Fast Stochastic Oscillator. +/// +/// # Arguments +/// - `high` – `Float64Array` of high prices. +/// - `low` – `Float64Array` of low prices. +/// - `close` – `Float64Array` of close prices. +/// - `fastk_period` – fast-%K look-back window (default 5, minimum 1). +/// - `fastd_period` – fast-%D SMA smoothing period (default 3, minimum 1). +/// +/// # Returns +/// A `js_sys::Array` containing two `Float64Array` elements: `[fastk, fastd]`. +#[wasm_bindgen] +pub fn stochf( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + fastk_period: usize, + fastd_period: usize, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + // 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)); + out +} + +// --------------------------------------------------------------------------- +// ADX — Average Directional Movement Index +// --------------------------------------------------------------------------- + +/// Average Directional Movement Index (Wilder smoothing). +/// +/// # Arguments +/// - `high` – `Float64Array` of high prices. +/// - `low` – `Float64Array` of low prices. +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back period (default 14, minimum 1). +/// +/// # Returns +/// `Float64Array`; warm-up values are `NaN`. +#[wasm_bindgen] +pub fn adx( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + if h.len() != l.len() || h.len() != c.len() { + return from_vec(vec![f64::NAN; c.len()]); + } + from_vec(ferro_ta_core::momentum::adx(&h, &l, &c, timeperiod)) +} + +// --------------------------------------------------------------------------- +// MFI — Money Flow Index +// --------------------------------------------------------------------------- + +/// Money Flow Index. +/// +/// # Arguments +/// - `high` – `Float64Array` of high prices. +/// - `low` – `Float64Array` of low prices. +/// - `close` – `Float64Array` of close prices. +/// - `volume` – `Float64Array` of volume values. +/// - `timeperiod` – look-back period (default 14, minimum 1). +/// +/// # Returns +/// `Float64Array`; warm-up values are `NaN`. +#[wasm_bindgen] +pub fn mfi( + 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); + let n = c.len(); + if h.len() != n || l.len() != n || v.len() != n { + return from_vec(vec![f64::NAN; n]); + } + from_vec(ferro_ta_core::volume::mfi(&h, &l, &c, &v, timeperiod)) +} + +// --------------------------------------------------------------------------- +// MACD — Moving Average Convergence/Divergence +// --------------------------------------------------------------------------- + +/// Moving Average Convergence/Divergence. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `fastperiod` – fast EMA period (default 12). +/// - `slowperiod` – slow EMA period (default 26). +/// - `signalperiod` – signal EMA period (default 9). +/// +/// # Returns +/// A `js_sys::Array` containing three `Float64Array` elements: +/// `[macd_line, signal_line, histogram]`. +#[wasm_bindgen] +pub fn macd( + close: &Float64Array, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> Array { + let prices = to_vec(close); + 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)); + out.push(&from_vec(histogram)); + 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)) +} + +// =========================================================================== +// Momentum (additional exports) +// =========================================================================== + +/// Full Stochastic Oscillator (slow %K and slow %D). +#[wasm_bindgen] +pub fn stoch( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (slowk, slowd) = ferro_ta_core::momentum::stoch(&h, &l, &c, fastk_period, slowk_period, slowd_period); + let out = Array::new(); + out.push(&from_vec(slowk)); + out.push(&from_vec(slowd)); + out +} + +/// Plus Directional Movement (+DM). +#[wasm_bindgen] +pub fn plus_dm(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + from_vec(ferro_ta_core::momentum::plus_dm(&h, &l, timeperiod)) +} + +/// Minus Directional Movement (-DM). +#[wasm_bindgen] +pub fn minus_dm(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + from_vec(ferro_ta_core::momentum::minus_dm(&h, &l, timeperiod)) +} + +/// Plus Directional Indicator (+DI). +#[wasm_bindgen] +pub fn plus_di(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::momentum::plus_di(&h, &l, &c, timeperiod)) +} + +/// Minus Directional Indicator (-DI). +#[wasm_bindgen] +pub fn minus_di(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::momentum::minus_di(&h, &l, &c, timeperiod)) +} + +/// Directional Movement Index (DX). +#[wasm_bindgen] +pub fn dx(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::momentum::dx(&h, &l, &c, timeperiod)) +} + +/// Average Directional Movement Index Rating (ADXR). +#[wasm_bindgen] +pub fn adxr(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::momentum::adxr(&h, &l, &c, timeperiod)) +} + +/// All ADX components: returns [+DM, -DM, +DI, -DI, DX, ADX]. +#[wasm_bindgen] +pub fn adx_all(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (pdm, mdm, pdi, mdi, dxv, adxv) = ferro_ta_core::momentum::adx_all(&h, &l, &c, timeperiod); + let out = Array::new(); + out.push(&from_vec(pdm)); + out.push(&from_vec(mdm)); + out.push(&from_vec(pdi)); + out.push(&from_vec(mdi)); + out.push(&from_vec(dxv)); + out.push(&from_vec(adxv)); + out +} + +// =========================================================================== +// Overlap Studies (additional exports) +// =========================================================================== + +/// Double Exponential Moving Average. +#[wasm_bindgen] +pub fn dema(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::dema(&to_vec(close), timeperiod)) +} + +/// Triple Exponential Moving Average. +#[wasm_bindgen] +pub fn tema(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::tema(&to_vec(close), timeperiod)) +} + +/// Triangular Moving Average. +#[wasm_bindgen] +pub fn trima(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::trima(&to_vec(close), timeperiod)) +} + +/// Kaufman Adaptive Moving Average. +#[wasm_bindgen] +pub fn kama(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::kama(&to_vec(close), timeperiod)) +} + +/// Tillson T3. +#[wasm_bindgen] +pub fn t3(close: &Float64Array, timeperiod: usize, vfactor: f64) -> Float64Array { + from_vec(ferro_ta_core::overlap::t3(&to_vec(close), timeperiod, vfactor)) +} + +/// Parabolic SAR. +#[wasm_bindgen] +pub fn sar(high: &Float64Array, low: &Float64Array, acceleration: f64, maximum: f64) -> Float64Array { + from_vec(ferro_ta_core::overlap::sar(&to_vec(high), &to_vec(low), acceleration, maximum)) +} + +/// Parabolic SAR Extended. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn sarext( + high: &Float64Array, low: &Float64Array, + startvalue: f64, offsetonreverse: f64, + accelerationinitlong: f64, accelerationlong: f64, accelerationmaxlong: f64, + accelerationinitshort: f64, accelerationshort: f64, accelerationmaxshort: f64, +) -> Float64Array { + from_vec(ferro_ta_core::overlap::sarext( + &to_vec(high), &to_vec(low), + startvalue, offsetonreverse, + accelerationinitlong, accelerationlong, accelerationmaxlong, + accelerationinitshort, accelerationshort, accelerationmaxshort, + )) +} + +/// MESA Adaptive Moving Average. Returns [mama, fama]. +#[wasm_bindgen] +pub fn mama(close: &Float64Array, fastlimit: f64, slowlimit: f64) -> Array { + let (m, f) = ferro_ta_core::overlap::mama(&to_vec(close), fastlimit, slowlimit); + let out = Array::new(); + out.push(&from_vec(m)); + out.push(&from_vec(f)); + out +} + +/// Midpoint over rolling window. +#[wasm_bindgen] +pub fn midpoint(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::midpoint(&to_vec(close), timeperiod)) +} + +/// MidPrice over rolling window. +#[wasm_bindgen] +pub fn midprice(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::midprice(&to_vec(high), &to_vec(low), timeperiod)) +} + +/// MACD with fixed 12/26 periods. Returns [macd, signal, histogram]. +#[wasm_bindgen] +pub fn macdfix(close: &Float64Array, signalperiod: usize) -> Array { + let (m, s, h) = ferro_ta_core::overlap::macdfix(&to_vec(close), signalperiod); + let out = Array::new(); + out.push(&from_vec(m)); + out.push(&from_vec(s)); + out.push(&from_vec(h)); + out +} + +/// MACD with configurable MA types. Returns [macd, signal, histogram]. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn macdext( + close: &Float64Array, fastperiod: usize, fastmatype: u8, + slowperiod: usize, slowmatype: u8, signalperiod: usize, signalmatype: u8, +) -> Array { + let (m, s, h) = ferro_ta_core::overlap::macdext( + &to_vec(close), fastperiod, fastmatype, slowperiod, slowmatype, signalperiod, signalmatype, + ); + let out = Array::new(); + out.push(&from_vec(m)); + out.push(&from_vec(s)); + out.push(&from_vec(h)); + out +} + +/// Generic Moving Average (matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3). +#[wasm_bindgen] +pub fn ma(close: &Float64Array, timeperiod: usize, matype: u8) -> Float64Array { + from_vec(ferro_ta_core::overlap::ma(&to_vec(close), timeperiod, matype)) +} + +/// Moving Average with Variable Period. +#[wasm_bindgen] +pub fn mavp(close: &Float64Array, periods: &Float64Array, minperiod: usize, maxperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::mavp(&to_vec(close), &to_vec(periods), minperiod, maxperiod)) +} + +// =========================================================================== +// Momentum (additional exports — new core indicators) +// =========================================================================== + +/// Rate of Change: `(close[i] - close[i-p]) / close[i-p] * 100`. +#[wasm_bindgen] +pub fn roc(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::roc(&to_vec(close), timeperiod)) +} + +/// Rate of Change Percentage. +#[wasm_bindgen] +pub fn rocp(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::rocp(&to_vec(close), timeperiod)) +} + +/// Rate of Change Ratio. +#[wasm_bindgen] +pub fn rocr(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::rocr(&to_vec(close), timeperiod)) +} + +/// Rate of Change Ratio x 100. +#[wasm_bindgen] +pub fn rocr100(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::rocr100(&to_vec(close), timeperiod)) +} + +/// Williams %R. +#[wasm_bindgen] +pub fn willr(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::willr(&to_vec(high), &to_vec(low), &to_vec(close), timeperiod)) +} + +/// Aroon indicator. Returns [aroon_down, aroon_up]. +#[wasm_bindgen] +pub fn aroon(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Array { + let (down, up) = ferro_ta_core::momentum::aroon(&to_vec(high), &to_vec(low), timeperiod); + let out = Array::new(); + out.push(&from_vec(down)); + out.push(&from_vec(up)); + out +} + +/// Aroon Oscillator. +#[wasm_bindgen] +pub fn aroonosc(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::aroonosc(&to_vec(high), &to_vec(low), timeperiod)) +} + +/// Commodity Channel Index. +#[wasm_bindgen] +pub fn cci(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::cci(&to_vec(high), &to_vec(low), &to_vec(close), timeperiod)) +} + +/// Balance of Power. +#[wasm_bindgen] +pub fn bop(open: &Float64Array, high: &Float64Array, low: &Float64Array, close: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::momentum::bop(&to_vec(open), &to_vec(high), &to_vec(low), &to_vec(close))) +} + +/// Stochastic RSI. Returns [fastk, fastd]. +#[wasm_bindgen] +pub fn stochrsi(close: &Float64Array, timeperiod: usize, fastk_period: usize, fastd_period: usize) -> Array { + let (k, d) = ferro_ta_core::momentum::stochrsi(&to_vec(close), timeperiod, fastk_period, fastd_period); + let out = Array::new(); + out.push(&from_vec(k)); + out.push(&from_vec(d)); + out +} + +/// Absolute Price Oscillator. +#[wasm_bindgen] +pub fn apo(close: &Float64Array, fastperiod: usize, slowperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::apo(&to_vec(close), fastperiod, slowperiod)) +} + +/// Percentage Price Oscillator. Returns [ppo, signal, histogram]. +#[wasm_bindgen] +pub fn ppo(close: &Float64Array, fastperiod: usize, slowperiod: usize, signalperiod: usize) -> Array { + let (p, s, h) = ferro_ta_core::momentum::ppo(&to_vec(close), fastperiod, slowperiod, signalperiod); + let out = Array::new(); + out.push(&from_vec(p)); + out.push(&from_vec(s)); + out.push(&from_vec(h)); + out +} + +/// Chande Momentum Oscillator. +#[wasm_bindgen] +pub fn cmo(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::cmo(&to_vec(close), timeperiod)) +} + +/// TRIX: 1-period rate of change of triple-smoothed EMA. +#[wasm_bindgen] +pub fn trix_indicator(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::trix(&to_vec(close), timeperiod)) +} + +/// Ultimate Oscillator. +#[wasm_bindgen] +pub fn ultosc(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod1: usize, timeperiod2: usize, timeperiod3: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::ultosc(&to_vec(high), &to_vec(low), &to_vec(close), timeperiod1, timeperiod2, timeperiod3)) +} + +// =========================================================================== +// Volatility (additional exports) +// =========================================================================== + +/// True Range. +#[wasm_bindgen] +pub fn trange(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::volatility::trange(&h, &l, &c)) +} + +/// Normalized Average True Range: ATR / close * 100. +#[wasm_bindgen] +pub fn natr(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::volatility::natr(&to_vec(high), &to_vec(low), &to_vec(close), timeperiod)) +} + +// =========================================================================== +// Statistic (additional exports) +// =========================================================================== + +/// Rolling population standard deviation scaled by `nbdev`. +#[wasm_bindgen] +pub fn stddev(close: &Float64Array, timeperiod: usize, nbdev: f64) -> Float64Array { + from_vec(ferro_ta_core::statistic::stddev(&to_vec(close), timeperiod, nbdev)) +} + +/// Rolling population variance scaled by `nbdev²`. +#[wasm_bindgen] +pub fn var(close: &Float64Array, timeperiod: usize, nbdev: f64) -> Float64Array { + from_vec(ferro_ta_core::statistic::var(&to_vec(close), timeperiod, nbdev)) +} + +/// Linear regression fitted value. +#[wasm_bindgen] +pub fn linearreg(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::linearreg(&to_vec(close), timeperiod)) +} + +/// Linear regression slope. +#[wasm_bindgen] +pub fn linearreg_slope(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::linearreg_slope(&to_vec(close), timeperiod)) +} + +/// Linear regression intercept. +#[wasm_bindgen] +pub fn linearreg_intercept(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::linearreg_intercept(&to_vec(close), timeperiod)) +} + +/// Linear regression angle in degrees. +#[wasm_bindgen] +pub fn linearreg_angle(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::linearreg_angle(&to_vec(close), timeperiod)) +} + +/// Time Series Forecast. +#[wasm_bindgen] +pub fn tsf(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::tsf(&to_vec(close), timeperiod)) +} + +/// Rolling beta (return-based regression). +#[wasm_bindgen] +pub fn beta_rolling(real0: &Float64Array, real1: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::beta(&to_vec(real0), &to_vec(real1), timeperiod)) +} + +/// Rolling Pearson correlation. +#[wasm_bindgen] +pub fn correl(real0: &Float64Array, real1: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::correl(&to_vec(real0), &to_vec(real1), timeperiod)) +} + +/// Dynamic Time Warping distance between two series. +/// +/// Returns the accumulated Euclidean cost along the optimal warping path. +/// Pass `window` as `0` for unconstrained (no Sakoe-Chiba band). +#[wasm_bindgen] +pub fn dtw_distance(series1: &Float64Array, series2: &Float64Array, window: usize) -> f64 { + let s1 = to_vec(series1); + let s2 = to_vec(series2); + let w = if window == 0 { None } else { Some(window) }; + ferro_ta_core::statistic::dtw_distance(&s1, &s2, w) +} + +// =========================================================================== +// Streaming / Stateful API +// =========================================================================== + +/// Streaming Simple Moving Average. +#[wasm_bindgen] +pub struct WasmStreamingSMA { + inner: ferro_ta_core::streaming::StreamingSMA, +} + +#[wasm_bindgen] +impl WasmStreamingSMA { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingSMA::new(period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> f64 { self.inner.update(value) } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming Exponential Moving Average. +#[wasm_bindgen] +pub struct WasmStreamingEMA { + inner: ferro_ta_core::streaming::StreamingEMA, +} + +#[wasm_bindgen] +impl WasmStreamingEMA { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingEMA::new(period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> f64 { self.inner.update(value) } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming Relative Strength Index. +#[wasm_bindgen] +pub struct WasmStreamingRSI { + inner: ferro_ta_core::streaming::StreamingRSI, +} + +#[wasm_bindgen] +impl WasmStreamingRSI { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingRSI::new(period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> f64 { self.inner.update(value) } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming Average True Range. +#[wasm_bindgen] +pub struct WasmStreamingATR { + inner: ferro_ta_core::streaming::StreamingATR, +} + +#[wasm_bindgen] +impl WasmStreamingATR { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingATR::new(period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + 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(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming Bollinger Bands. Returns [upper, middle, lower] from `update()`. +#[wasm_bindgen] +pub struct WasmStreamingBBands { + inner: ferro_ta_core::streaming::StreamingBBands, +} + +#[wasm_bindgen] +impl WasmStreamingBBands { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> Result { + let inner = ferro_ta_core::streaming::StreamingBBands::new(period, nbdevup, nbdevdn) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> Array { + let (u, m, l) = self.inner.update(value); + let out = Array::new(); + out.push(&JsValue::from_f64(u)); + out.push(&JsValue::from_f64(m)); + out.push(&JsValue::from_f64(l)); + out + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming MACD. Returns [macd, signal, histogram] from `update()`. +#[wasm_bindgen] +pub struct WasmStreamingMACD { + inner: ferro_ta_core::streaming::StreamingMACD, +} + +#[wasm_bindgen] +impl WasmStreamingMACD { + #[wasm_bindgen(constructor)] + pub fn new(fastperiod: usize, slowperiod: usize, signalperiod: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingMACD::new(fastperiod, slowperiod, signalperiod) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> Array { + let (m, s, h) = self.inner.update(value); + let out = Array::new(); + out.push(&JsValue::from_f64(m)); + out.push(&JsValue::from_f64(s)); + out.push(&JsValue::from_f64(h)); + out + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn fast_period(&self) -> usize { self.inner.fast_period() } + #[wasm_bindgen(getter)] + pub fn slow_period(&self) -> usize { self.inner.slow_period() } + #[wasm_bindgen(getter)] + pub fn signal_period(&self) -> usize { self.inner.signal_period() } +} + +/// Streaming Stochastic Oscillator. Returns [slowk, slowd] from `update()`. +#[wasm_bindgen] +pub struct WasmStreamingStoch { + inner: ferro_ta_core::streaming::StreamingStoch, +} + +#[wasm_bindgen] +impl WasmStreamingStoch { + #[wasm_bindgen(constructor)] + pub fn new(fastk_period: usize, slowk_period: usize, slowd_period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingStoch::new(fastk_period, slowk_period, slowd_period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Array { + let (sk, sd) = self.inner.update(high, low, close); + let out = Array::new(); + out.push(&JsValue::from_f64(sk)); + out.push(&JsValue::from_f64(sd)); + out + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming cumulative VWAP. +#[wasm_bindgen] +pub struct WasmStreamingVWAP { + inner: ferro_ta_core::streaming::StreamingVWAP, +} + +#[wasm_bindgen] +impl WasmStreamingVWAP { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmStreamingVWAP { + Self { inner: ferro_ta_core::streaming::StreamingVWAP::new() } + } + pub fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> f64 { + self.inner.update(high, low, close, volume) + } + pub fn reset(&mut self) { self.inner.reset(); } +} + +/// Streaming Supertrend. Returns [line, direction] from `update()`. +#[wasm_bindgen] +pub struct WasmStreamingSupertrend { + inner: ferro_ta_core::streaming::StreamingSupertrend, +} + +#[wasm_bindgen] +impl WasmStreamingSupertrend { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, multiplier: f64) -> Result { + let inner = ferro_ta_core::streaming::StreamingSupertrend::new(period, multiplier) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Array { + let (line, dir) = self.inner.update(high, low, close); + let out = Array::new(); + out.push(&JsValue::from_f64(line)); + out.push(&JsValue::from_f64(dir as f64)); + out + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +// =========================================================================== +// Batch Operations +// =========================================================================== + +/// Convert a js_sys::Array of Float64Array into Vec>. +fn array_of_f64arr_to_vecs(arr: &Array) -> Vec> { + (0..arr.length()) + .map(|i| { + let item: Float64Array = arr.get(i).unchecked_into(); + to_vec(&item) + }) + .collect() +} + +/// Convert Vec> into a js_sys::Array of Float64Array. +fn vecs_to_array_of_f64arr(data: Vec>) -> Array { + let out = Array::new(); + for v in data { + out.push(&from_vec(v)); + } + out +} + +/// Batch SMA: compute SMA on each column of 2D data. +#[wasm_bindgen] +pub fn batch_sma(data: &Array, timeperiod: usize) -> Array { + let vecs = array_of_f64arr_to_vecs(data); + match ferro_ta_core::batch::batch_sma(&vecs, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +/// Batch EMA: compute EMA on each column of 2D data. +#[wasm_bindgen] +pub fn batch_ema(data: &Array, timeperiod: usize) -> Array { + let vecs = array_of_f64arr_to_vecs(data); + match ferro_ta_core::batch::batch_ema(&vecs, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +/// Batch RSI: compute RSI on each column of 2D data. +#[wasm_bindgen] +pub fn batch_rsi(data: &Array, timeperiod: usize) -> Array { + let vecs = array_of_f64arr_to_vecs(data); + match ferro_ta_core::batch::batch_rsi(&vecs, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +// =========================================================================== +// Portfolio (additional exports) +// =========================================================================== + +/// Portfolio volatility: sqrt(w' * cov * w). +#[wasm_bindgen] +pub fn portfolio_volatility(cov_matrix: &Array, weights: &Float64Array) -> f64 { + let cov = array_of_f64arr_to_vecs(cov_matrix); + let w = to_vec(weights); + ferro_ta_core::portfolio::portfolio_volatility(&cov, &w) +} + +/// Pairwise correlation matrix. +#[wasm_bindgen] +pub fn correlation_matrix(data: &Array) -> Array { + let vecs = array_of_f64arr_to_vecs(data); + vecs_to_array_of_f64arr(ferro_ta_core::portfolio::correlation_matrix(&vecs)) +} + +/// Weighted composite of multiple series. +#[wasm_bindgen] +pub fn compose_weighted(data: &Array, weights: &Float64Array) -> Float64Array { + let vecs = array_of_f64arr_to_vecs(data); + let w = to_vec(weights); + from_vec(ferro_ta_core::portfolio::compose_weighted(&vecs, &w)) +} + +// =========================================================================== +// Crypto (additional exports) +// =========================================================================== + +/// Mark session boundaries from nanosecond timestamps. +#[wasm_bindgen] +pub fn mark_session_boundaries(timestamps_ns: &Float64Array) -> Float64Array { + let ts: Vec = to_vec(timestamps_ns).iter().map(|&v| v as i64).collect(); + let result = ferro_ta_core::crypto::mark_session_boundaries(&ts); + from_vec(result.iter().map(|&v| v as f64).collect()) +} + +// =========================================================================== +// Chunked (additional exports) +// =========================================================================== + +/// Stitch multiple chunks into a single array. +#[wasm_bindgen] +pub fn stitch_chunks(chunks: &Array) -> Float64Array { + let vecs = array_of_f64arr_to_vecs(chunks); + let slices: Vec<&[f64]> = vecs.iter().map(|v| v.as_slice()).collect(); + from_vec(ferro_ta_core::chunked::stitch_chunks(&slices)) +} + +// =========================================================================== +// Math Operators & Transforms +// =========================================================================== + +/// Element-wise addition. +#[wasm_bindgen] +pub fn math_add(a: &Float64Array, b: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::add(&to_vec(a), &to_vec(b))) +} + +/// Element-wise subtraction. +#[wasm_bindgen] +pub fn math_sub(a: &Float64Array, b: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::sub(&to_vec(a), &to_vec(b))) +} + +/// Element-wise multiplication. +#[wasm_bindgen] +pub fn math_mult(a: &Float64Array, b: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::mult(&to_vec(a), &to_vec(b))) +} + +/// Element-wise division. +#[wasm_bindgen] +pub fn math_div(a: &Float64Array, b: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::div(&to_vec(a), &to_vec(b))) +} + +macro_rules! math_transform_wrapper { + ($wasm_name:ident, $core_name:ident) => { + #[wasm_bindgen] + pub fn $wasm_name(real: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::$core_name(&to_vec(real))) + } + }; +} + +math_transform_wrapper!(transform_acos, math_acos); +math_transform_wrapper!(transform_asin, math_asin); +math_transform_wrapper!(transform_atan, math_atan); +math_transform_wrapper!(transform_ceil, math_ceil); +math_transform_wrapper!(transform_cos, math_cos); +math_transform_wrapper!(transform_cosh, math_cosh); +math_transform_wrapper!(transform_exp, math_exp); +math_transform_wrapper!(transform_floor, math_floor); +math_transform_wrapper!(transform_ln, math_ln); +math_transform_wrapper!(transform_log10, math_log10); +math_transform_wrapper!(transform_sin, math_sin); +math_transform_wrapper!(transform_sinh, math_sinh); +math_transform_wrapper!(transform_sqrt, math_sqrt); +math_transform_wrapper!(transform_tan, math_tan); +math_transform_wrapper!(transform_tanh, math_tanh); + +// =========================================================================== +// Candlestick Patterns (61 functions via macro) +// =========================================================================== + +/// Convert a `Vec` into a `js_sys::Int32Array`. +fn from_i32_vec(v: Vec) -> js_sys::Int32Array { + let arr = js_sys::Int32Array::new_with_length(v.len() as u32); + arr.copy_from(&v); + arr +} + +macro_rules! cdl_wrapper { + ($($name:ident),* $(,)?) => {$( + #[wasm_bindgen] + pub fn $name( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + ) -> js_sys::Int32Array { + let o = to_vec(open); + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_i32_vec(ferro_ta_core::pattern::$name(&o, &h, &l, &c)) + } + )*}; +} + +cdl_wrapper!( + cdl2crows, + cdl3blackcrows, + cdl3inside, + cdl3linestrike, + cdl3outside, + cdl3starsinsouth, + cdl3whitesoldiers, + cdlabandonedbaby, + cdladvanceblock, + cdlbelthold, + cdlbreakaway, + cdlclosingmarubozu, + cdlconcealbabyswall, + cdlcounterattack, + cdldarkcloudcover, + cdldoji, + cdldojistar, + cdldragonflydoji, + cdlengulfing, + cdleveningdojistar, + cdleveningstar, + cdlgapsidesidewhite, + cdlgravestonedoji, + cdlhammer, + cdlhangingman, + cdlharami, + cdlharamicross, + cdlhighwave, + cdlhikkake, + cdlhikkakemod, + cdlhomingpigeon, + cdlidentical3crows, + cdlinneck, + cdlinvertedhammer, + cdlkicking, + cdlkickingbylength, + cdlladderbottom, + cdllongleggeddoji, + cdllongline, + cdlmarubozu, + cdlmatchinglow, + cdlmathold, + cdlmorningdojistar, + cdlmorningstar, + cdlonneck, + cdlpiercing, + cdlrickshawman, + cdlrisefall3methods, + cdlseparatinglines, + cdlshootingstar, + cdlshortline, + cdlspinningtop, + cdlstalledpattern, + cdlsticksandwich, + cdltakuri, + cdltasukigap, + cdlthrusting, + cdltristar, + cdlunique3river, + cdlupsidegap2crows, + cdlxsidegap3methods, +); + +// =========================================================================== +// Signals (additional) +// =========================================================================== + +/// Rank values (percentile ranking [0, 100]). +#[wasm_bindgen] +pub fn rank_values(x: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::signals::rank_values(&to_vec(x))) +} + +/// Composite rank across multiple signal arrays. +#[wasm_bindgen] +pub fn compose_rank(signals: &Array) -> Float64Array { + let vecs = array_of_f64arr_to_vecs(signals); + let slices: Vec<&[f64]> = vecs.iter().map(|v| v.as_slice()).collect(); + from_vec(ferro_ta_core::signals::compose_rank(&slices)) +} + +// =========================================================================== +// Batch (additional) +// =========================================================================== + +/// Batch ATR across multiple HLC column sets. +#[wasm_bindgen] +pub fn batch_atr(high: &Array, low: &Array, close: &Array, timeperiod: usize) -> Array { + let h = array_of_f64arr_to_vecs(high); + let l = array_of_f64arr_to_vecs(low); + let c = array_of_f64arr_to_vecs(close); + match ferro_ta_core::batch::batch_atr(&h, &l, &c, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +/// Batch Stochastic across multiple HLC column sets. Returns [Array[slowk_cols], Array[slowd_cols]]. +#[wasm_bindgen] +pub fn batch_stoch(high: &Array, low: &Array, close: &Array, fastk_period: usize, slowk_period: usize, slowd_period: usize) -> Array { + let h = array_of_f64arr_to_vecs(high); + let l = array_of_f64arr_to_vecs(low); + let c = array_of_f64arr_to_vecs(close); + match ferro_ta_core::batch::batch_stoch(&h, &l, &c, fastk_period, slowk_period, slowd_period) { + Ok((sk, sd)) => { + let out = Array::new(); + out.push(&vecs_to_array_of_f64arr(sk)); + out.push(&vecs_to_array_of_f64arr(sd)); + out + } + Err(_) => Array::new(), + } +} + +/// Batch ADX across multiple HLC column sets. +#[wasm_bindgen] +pub fn batch_adx(high: &Array, low: &Array, close: &Array, timeperiod: usize) -> Array { + let h = array_of_f64arr_to_vecs(high); + let l = array_of_f64arr_to_vecs(low); + let c = array_of_f64arr_to_vecs(close); + match ferro_ta_core::batch::batch_adx(&h, &l, &c, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +// =========================================================================== +// Options Analytics +// =========================================================================== + +fn parse_option_kind(kind: &str) -> ferro_ta_core::options::OptionKind { + match kind.to_lowercase().as_str() { + "put" | "p" => ferro_ta_core::options::OptionKind::Put, + _ => ferro_ta_core::options::OptionKind::Call, + } +} + +fn parse_pricing_model(model: &str) -> ferro_ta_core::options::PricingModel { + match model.to_lowercase().as_str() { + "black76" | "b76" => ferro_ta_core::options::PricingModel::Black76, + _ => ferro_ta_core::options::PricingModel::BlackScholes, + } +} + +/// Black-Scholes-Merton option price. +#[wasm_bindgen] +pub fn black_scholes_price( + spot: f64, strike: f64, rate: f64, dividend_yield: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> f64 { + ferro_ta_core::options::pricing::black_scholes_price( + spot, strike, rate, dividend_yield, time_to_expiry, volatility, parse_option_kind(kind), + ) +} + +/// Black-76 option price (futures). +#[wasm_bindgen] +pub fn black_76_price( + forward: f64, strike: f64, rate: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> f64 { + ferro_ta_core::options::pricing::black_76_price( + forward, strike, rate, time_to_expiry, volatility, parse_option_kind(kind), + ) +} + +/// Black-Scholes Greeks. Returns [delta, gamma, vega, theta, rho]. +#[wasm_bindgen] +pub fn black_scholes_greeks( + spot: f64, strike: f64, rate: f64, dividend_yield: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> Array { + let g = ferro_ta_core::options::greeks::black_scholes_greeks( + spot, strike, rate, dividend_yield, time_to_expiry, volatility, parse_option_kind(kind), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(g.delta)); + out.push(&JsValue::from_f64(g.gamma)); + out.push(&JsValue::from_f64(g.vega)); + out.push(&JsValue::from_f64(g.theta)); + out.push(&JsValue::from_f64(g.rho)); + out +} + +/// Black-76 Greeks. Returns [delta, gamma, vega, theta, rho]. +#[wasm_bindgen] +pub fn black_76_greeks( + forward: f64, strike: f64, rate: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> Array { + let g = ferro_ta_core::options::greeks::black_76_greeks( + forward, strike, rate, time_to_expiry, volatility, parse_option_kind(kind), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(g.delta)); + out.push(&JsValue::from_f64(g.gamma)); + out.push(&JsValue::from_f64(g.vega)); + out.push(&JsValue::from_f64(g.theta)); + out.push(&JsValue::from_f64(g.rho)); + out +} + +/// Implied volatility via Newton-Raphson. +#[wasm_bindgen] +pub fn implied_volatility( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, kind: &str, target_price: f64, + initial_guess: f64, tolerance: f64, max_iterations: usize, +) -> f64 { + use ferro_ta_core::options::*; + let contract = OptionContract { + model: parse_pricing_model(model), + underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }; + let config = IvSolverConfig { initial_guess, tolerance, max_iterations }; + iv::implied_volatility(contract, target_price, config) +} + +/// IV Rank over a rolling window. +#[wasm_bindgen] +pub fn iv_rank(iv_series: &Float64Array, window: usize) -> Float64Array { + from_vec(ferro_ta_core::options::iv::iv_rank(&to_vec(iv_series), window)) +} + +/// IV Percentile over a rolling window. +#[wasm_bindgen] +pub fn iv_percentile(iv_series: &Float64Array, window: usize) -> Float64Array { + from_vec(ferro_ta_core::options::iv::iv_percentile(&to_vec(iv_series), window)) +} + +/// IV Z-Score over a rolling window. +#[wasm_bindgen] +pub fn iv_zscore(iv_series: &Float64Array, window: usize) -> Float64Array { + from_vec(ferro_ta_core::options::iv::iv_zscore(&to_vec(iv_series), window)) +} + +/// ATM index in a strikes array. +#[wasm_bindgen] +pub fn atm_index(strikes: &Float64Array, reference_price: f64) -> f64 { + match ferro_ta_core::options::chain::atm_index(&to_vec(strikes), reference_price) { + Some(idx) => idx as f64, + None => f64::NAN, + } +} + +/// Label moneyness of strikes. Returns Int8Array. +#[wasm_bindgen] +pub fn label_moneyness(strikes: &Float64Array, reference_price: f64, kind: &str) -> js_sys::Int8Array { + let result = ferro_ta_core::options::chain::label_moneyness( + &to_vec(strikes), reference_price, parse_option_kind(kind), + ); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Model-dispatched option price (model: "bs" or "b76"). +#[wasm_bindgen] +pub fn model_price( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> f64 { + use ferro_ta_core::options::*; + let input = OptionEvaluation { + contract: OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }, + volatility, + }; + pricing::model_price(input) +} + +/// Model-dispatched Greeks. Returns [delta, gamma, vega, theta, rho]. +#[wasm_bindgen] +pub fn model_greeks( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> Array { + use ferro_ta_core::options::*; + let input = OptionEvaluation { + contract: OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }, + volatility, + }; + let g = greeks::model_greeks(input); + let out = Array::new(); + out.push(&JsValue::from_f64(g.delta)); + out.push(&JsValue::from_f64(g.gamma)); + out.push(&JsValue::from_f64(g.vega)); + out.push(&JsValue::from_f64(g.theta)); + out.push(&JsValue::from_f64(g.rho)); + out +} + +/// Model theta (numerical). +#[wasm_bindgen] +pub fn model_theta( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> f64 { + use ferro_ta_core::options::*; + let input = OptionEvaluation { + contract: OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }, + volatility, + }; + greeks::model_theta(input) +} + +/// Price lower bound. +#[wasm_bindgen] +pub fn price_lower_bound( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, kind: &str, +) -> f64 { + use ferro_ta_core::options::*; + let contract = OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }; + pricing::price_lower_bound(contract) +} + +/// Price upper bound. +#[wasm_bindgen] +pub fn price_upper_bound( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, kind: &str, +) -> f64 { + use ferro_ta_core::options::*; + let contract = OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }; + pricing::price_upper_bound(contract) +} + +/// Select strike by offset from ATM. +#[wasm_bindgen] +pub fn select_strike_by_offset(strikes: &Float64Array, reference_price: f64, offset: i32) -> f64 { + match ferro_ta_core::options::chain::select_strike_by_offset( + &to_vec(strikes), reference_price, offset as isize, + ) { + Some(v) => v, + None => f64::NAN, + } +} + +/// Smile metrics. Returns [atm_iv, risk_reversal_25d, butterfly_25d, skew_slope, convexity]. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn smile_metrics( + strikes: &Float64Array, vols: &Float64Array, reference_price: f64, + rate: f64, carry: f64, time_to_expiry: f64, model: &str, +) -> Array { + let m = ferro_ta_core::options::surface::smile_metrics( + &to_vec(strikes), &to_vec(vols), reference_price, + rate, carry, time_to_expiry, parse_pricing_model(model), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(m.atm_iv)); + out.push(&JsValue::from_f64(m.risk_reversal_25d)); + out.push(&JsValue::from_f64(m.butterfly_25d)); + out.push(&JsValue::from_f64(m.skew_slope)); + out.push(&JsValue::from_f64(m.convexity)); + out +} + +/// Linear interpolation helper. +#[wasm_bindgen] +pub fn linear_interpolate(xs: &Float64Array, ys: &Float64Array, target: f64) -> f64 { + ferro_ta_core::options::surface::linear_interpolate(&to_vec(xs), &to_vec(ys), target) +} + +/// Select strike by delta target. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn select_strike_by_delta( + strikes: &Float64Array, vols: &Float64Array, + model: &str, reference_price: f64, rate: f64, carry: f64, + time_to_expiry: f64, kind: &str, target_delta: f64, +) -> f64 { + use ferro_ta_core::options::*; + let ctx = ChainGreeksContext { + model: parse_pricing_model(model), + reference_price, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }; + match chain::select_strike_by_delta(&to_vec(strikes), &to_vec(vols), ctx, target_delta) { + Some(v) => v, + None => f64::NAN, + } +} + +/// ATM implied volatility interpolated from strikes/vols. +#[wasm_bindgen] +pub fn atm_iv(strikes: &Float64Array, vols: &Float64Array, reference_price: f64) -> f64 { + ferro_ta_core::options::surface::atm_iv(&to_vec(strikes), &to_vec(vols), reference_price) +} + +/// Term structure slope. +#[wasm_bindgen] +pub fn term_structure_slope(tenors: &Float64Array, atm_ivs: &Float64Array) -> f64 { + ferro_ta_core::options::surface::term_structure_slope(&to_vec(tenors), &to_vec(atm_ivs)) +} + +// =========================================================================== +// Futures Analytics +// =========================================================================== + +/// Futures basis: future - spot. +#[wasm_bindgen] +pub fn futures_basis(spot: f64, future: f64) -> f64 { + ferro_ta_core::futures::basis::basis(spot, future) +} + +/// Annualized basis. +#[wasm_bindgen] +pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::basis::annualized_basis(spot, future, time_to_expiry) +} + +/// Implied carry rate. +#[wasm_bindgen] +pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::basis::implied_carry_rate(spot, future, time_to_expiry) +} + +/// Carry spread. +#[wasm_bindgen] +pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::basis::carry_spread(spot, future, rate, time_to_expiry) +} + +/// Calendar spreads between consecutive futures prices. +#[wasm_bindgen] +pub fn calendar_spreads(futures_prices: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::futures::curve::calendar_spreads(&to_vec(futures_prices))) +} + +/// Curve slope (linear regression). +#[wasm_bindgen] +pub fn curve_slope(tenors: &Float64Array, futures_prices: &Float64Array) -> f64 { + ferro_ta_core::futures::curve::curve_slope(&to_vec(tenors), &to_vec(futures_prices)) +} + +/// Curve summary. Returns [front_basis, average_basis, slope, is_contango (1.0 or 0.0)]. +#[wasm_bindgen] +pub fn curve_summary(spot: f64, tenors: &Float64Array, futures_prices: &Float64Array) -> Array { + let s = ferro_ta_core::futures::curve::curve_summary(spot, &to_vec(tenors), &to_vec(futures_prices)); + let out = Array::new(); + out.push(&JsValue::from_f64(s.front_basis)); + out.push(&JsValue::from_f64(s.average_basis)); + out.push(&JsValue::from_f64(s.slope)); + out.push(&JsValue::from_f64(if s.is_contango { 1.0 } else { 0.0 })); + out +} + +/// Roll yield. +#[wasm_bindgen] +pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::roll::roll_yield(front_price, next_price, time_to_expiry) +} + +/// Weighted continuous contract. +#[wasm_bindgen] +pub fn weighted_continuous(front: &Float64Array, next: &Float64Array, next_weights: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::futures::roll::weighted_continuous(&to_vec(front), &to_vec(next), &to_vec(next_weights))) +} + +/// Back-adjusted continuous contract. +#[wasm_bindgen] +pub fn back_adjusted_continuous(front: &Float64Array, next: &Float64Array, next_weights: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::futures::roll::back_adjusted_continuous(&to_vec(front), &to_vec(next), &to_vec(next_weights))) +} + +/// Ratio-adjusted continuous contract. +#[wasm_bindgen] +pub fn ratio_adjusted_continuous(front: &Float64Array, next: &Float64Array, next_weights: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::futures::roll::ratio_adjusted_continuous(&to_vec(front), &to_vec(next), &to_vec(next_weights))) +} + +/// Synthetic forward price from put-call parity. +#[wasm_bindgen] +pub fn synthetic_forward(call_price: f64, put_price: f64, strike: f64, rate: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::synthetic::synthetic_forward(call_price, put_price, strike, rate, time_to_expiry) +} + +/// Synthetic spot implied by put-call parity. +#[wasm_bindgen] +pub fn synthetic_spot(call_price: f64, put_price: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::synthetic::synthetic_spot(call_price, put_price, strike, rate, carry, time_to_expiry) +} + +/// Put-call parity residual. +#[wasm_bindgen] +pub fn parity_gap(call_price: f64, put_price: f64, spot: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::synthetic::parity_gap(call_price, put_price, spot, strike, rate, carry, time_to_expiry) +} + +// =========================================================================== +// Backtesting (signal generators + utilities) +// =========================================================================== + +/// Backtest core: close-only vectorized backtest. Returns [positions, bar_returns, strategy_returns, equity]. +#[wasm_bindgen] +pub fn backtest_core( + close: &Float64Array, signals: &Float64Array, + slippage_bps: f64, initial_capital: f64, commission_per_trade: f64, +) -> Array { + match ferro_ta_core::backtest::backtest_core( + &to_vec(close), &to_vec(signals), None, slippage_bps, initial_capital, commission_per_trade, + ) { + Ok(result) => { + let out = Array::new(); + out.push(&from_vec(result.positions)); + out.push(&from_vec(result.bar_returns)); + out.push(&from_vec(result.strategy_returns)); + out.push(&from_vec(result.equity)); + out + } + Err(_) => Array::new(), + } +} + +/// Simple single-asset backtest. Returns [positions, strategy_returns, equity]. +#[wasm_bindgen] +pub fn single_asset_backtest( + close: &Float64Array, signals: &Float64Array, + commission_per_trade: f64, slippage_bps: f64, +) -> Array { + let (pos, strat_ret, eq) = ferro_ta_core::backtest::single_asset_backtest( + &to_vec(close), &to_vec(signals), commission_per_trade, slippage_bps, + ); + let out = Array::new(); + out.push(&from_vec(pos)); + out.push(&from_vec(strat_ret)); + out.push(&from_vec(eq)); + out +} + +/// Walk-forward train/test indices. Returns flat array [train_start, train_end, test_start, test_end, ...]. +#[wasm_bindgen] +pub fn walk_forward_indices( + n_bars: usize, train_bars: usize, test_bars: usize, anchored: bool, step_bars: usize, +) -> Float64Array { + match ferro_ta_core::backtest::walk_forward_indices(n_bars, train_bars, test_bars, anchored, step_bars) { + Ok(indices) => { + let flat: Vec = indices.iter() + .flat_map(|fold| vec![fold[0] as f64, fold[1] as f64, fold[2] as f64, fold[3] as f64]) + .collect(); + from_vec(flat) + } + Err(_) => from_vec(vec![]), + } +} + +/// Monte Carlo bootstrap of strategy returns. Returns Array of Float64Array (one per simulation). +#[wasm_bindgen] +pub fn monte_carlo_bootstrap( + strategy_returns: &Float64Array, n_sims: usize, seed: f64, block_size: usize, +) -> Array { + match ferro_ta_core::backtest::monte_carlo_bootstrap( + &to_vec(strategy_returns), n_sims, seed as u64, block_size, + ) { + Ok(sims) => vecs_to_array_of_f64arr(sims), + Err(_) => Array::new(), + } +} + +/// Kelly fraction. +#[wasm_bindgen] +pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> f64 { + ferro_ta_core::backtest::kelly_fraction(win_rate, avg_win, avg_loss).unwrap_or(f64::NAN) +} + +/// Half-Kelly fraction. +#[wasm_bindgen] +pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> f64 { + ferro_ta_core::backtest::half_kelly_fraction(win_rate, avg_win, avg_loss).unwrap_or(f64::NAN) +} + +/// Compute performance metrics from strategy returns and equity. +/// Returns Float64Array with 22 metrics in order: +/// [total_return, cagr, annualized_vol, sharpe, sortino, calmar, max_drawdown, +/// avg_drawdown, max_dd_duration, 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] +#[wasm_bindgen] +pub fn compute_performance_metrics( + strategy_returns: &Float64Array, equity: &Float64Array, + periods_per_year: f64, risk_free_rate: f64, +) -> Float64Array { + match ferro_ta_core::backtest::compute_performance_metrics( + &to_vec(strategy_returns), &to_vec(equity), periods_per_year, risk_free_rate, None, + ) { + Ok(m) => from_vec(vec![ + m.total_return, m.cagr, m.annualized_vol, m.sharpe, m.sortino, m.calmar, + m.max_drawdown, m.avg_drawdown, m.max_drawdown_duration_bars as f64, + m.avg_drawdown_duration_bars, m.ulcer_index, m.omega_ratio, + m.win_rate, m.profit_factor, m.r_expectancy, m.avg_win, m.avg_loss, + m.tail_ratio, m.skewness, m.kurtosis, m.best_bar, m.worst_bar, + ]), + Err(_) => from_vec(vec![]), + } +} + +/// OHLCV-aware backtest. Returns [positions, fill_prices, bar_returns, strategy_returns, equity]. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn backtest_ohlcv( + open: &Float64Array, high: &Float64Array, low: &Float64Array, close: &Float64Array, + signals: &Float64Array, slippage_bps: f64, initial_capital: f64, commission_per_trade: f64, + stop_loss_pct: f64, take_profit_pct: f64, trailing_stop_pct: f64, max_hold_bars: usize, +) -> Array { + let mut config = ferro_ta_core::backtest::BacktestConfig::default(); + config.slippage_bps = slippage_bps; + config.initial_capital = initial_capital; + config.commission_per_trade = commission_per_trade; + config.stop_loss_pct = stop_loss_pct; + config.take_profit_pct = take_profit_pct; + config.trailing_stop_pct = trailing_stop_pct; + config.max_hold_bars = max_hold_bars; + match ferro_ta_core::backtest::backtest_ohlcv_core( + &to_vec(open), &to_vec(high), &to_vec(low), &to_vec(close), + &to_vec(signals), &config, None, + ) { + Ok(r) => { + let out = Array::new(); + out.push(&from_vec(r.positions)); + out.push(&from_vec(r.fill_prices)); + out.push(&from_vec(r.bar_returns)); + out.push(&from_vec(r.strategy_returns)); + out.push(&from_vec(r.equity)); + out + } + Err(_) => Array::new(), + } +} + +/// RSI threshold signals. +#[wasm_bindgen] +pub fn rsi_threshold_signals(close: &Float64Array, timeperiod: usize, oversold: f64, overbought: f64) -> Float64Array { + from_vec(ferro_ta_core::backtest::rsi_threshold_signals(&to_vec(close), timeperiod, oversold, overbought)) +} + +/// SMA crossover signals. +#[wasm_bindgen] +pub fn sma_crossover_signals(close: &Float64Array, fast: usize, slow: usize) -> Float64Array { + match ferro_ta_core::backtest::sma_crossover_signals(&to_vec(close), fast, slow) { + Ok(v) => from_vec(v), + Err(_) => from_vec(vec![f64::NAN; close.length() as usize]), + } +} + +/// MACD crossover signals. +#[wasm_bindgen] +pub fn macd_crossover_signals(close: &Float64Array, fastperiod: usize, slowperiod: usize, signalperiod: usize) -> Float64Array { + match ferro_ta_core::backtest::macd_crossover_signals(&to_vec(close), fastperiod, slowperiod, signalperiod) { + Ok(v) => from_vec(v), + Err(_) => from_vec(vec![f64::NAN; close.length() as usize]), + } +} + +// =========================================================================== +// New Options Features (extended Greeks, digital, American, vol estimators, +// vol cone, expected move, put-call parity, strategy payoff/value/Greeks) +// =========================================================================== + +// --------------------------------------------------------------------------- +// Helpers shared by the new features +// --------------------------------------------------------------------------- + +fn parse_digital_kind(digital_type: &str) -> ferro_ta_core::options::digital::DigitalKind { + match digital_type.to_ascii_lowercase().as_str() { + "asset_or_nothing" | "asset" => ferro_ta_core::options::digital::DigitalKind::AssetOrNothing, + _ => ferro_ta_core::options::digital::DigitalKind::CashOrNothing, + } +} + +/// Convert a Float64Array to a Vec (for instrument/side/option_type codes). +fn to_i64_vec(arr: &Float64Array) -> Vec { + to_vec(arr).into_iter().map(|x| x as i64).collect() +} + +/// Convert a Float64Array to a Vec (for window sizes). +fn to_usize_vec(arr: &Float64Array) -> Vec { + to_vec(arr).into_iter().map(|x| x as usize).collect() +} + +// --------------------------------------------------------------------------- +// Put-call parity check +// --------------------------------------------------------------------------- + +/// Put-call parity deviation: `C - P - (S·e^{-qT} - K·e^{-rT})`. +/// +/// Returns 0 at no-arbitrage. +#[wasm_bindgen] +pub fn put_call_parity_deviation( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, +) -> f64 { + ferro_ta_core::options::pricing::put_call_parity_deviation( + call_price, put_price, spot, strike, rate, carry, time_to_expiry, + ) +} + +// --------------------------------------------------------------------------- +// Extended (higher-order) Greeks +// --------------------------------------------------------------------------- + +/// Extended BSM Greeks: vanna, volga, charm, speed, color. +/// +/// # Returns +/// `js_sys::Array` of five f64 values: `[vanna, volga, charm, speed, color]`. +#[wasm_bindgen] +pub fn extended_greeks( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, +) -> Array { + use ferro_ta_core::options::{greeks::model_extended_greeks, OptionContract, OptionEvaluation, PricingModel}; + let k = parse_option_kind(kind); + // In this codebase, `carry` = dividend yield q (same convention as all other WASM/PyO3 APIs). + let eg = model_extended_greeks(OptionEvaluation { + contract: OptionContract { + model: PricingModel::BlackScholes, + underlying: spot, + strike, + rate, + carry, + time_to_expiry, + kind: k, + }, + volatility, + }); + let out = Array::new(); + out.push(&JsValue::from_f64(eg.vanna)); + out.push(&JsValue::from_f64(eg.volga)); + out.push(&JsValue::from_f64(eg.charm)); + out.push(&JsValue::from_f64(eg.speed)); + out.push(&JsValue::from_f64(eg.color)); + out +} + +// --------------------------------------------------------------------------- +// Digital options +// --------------------------------------------------------------------------- + +/// Price a digital (binary) option. +/// +/// # Arguments +/// - `kind` – `"call"` or `"put"` +/// - `digital_type` – `"cash_or_nothing"` (default) or `"asset_or_nothing"` +#[wasm_bindgen] +pub fn digital_price( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, + digital_type: &str, +) -> f64 { + ferro_ta_core::options::digital::digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + parse_option_kind(kind), + parse_digital_kind(digital_type), + ) +} + +/// Greeks for a digital option (numerical central differences). +/// +/// # Returns +/// `js_sys::Array` of three f64 values: `[delta, gamma, vega]`. +#[wasm_bindgen] +pub fn digital_greeks( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, + digital_type: &str, +) -> Array { + let (delta, gamma, vega) = ferro_ta_core::options::digital::digital_greeks( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + parse_option_kind(kind), + parse_digital_kind(digital_type), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(delta)); + out.push(&JsValue::from_f64(gamma)); + out.push(&JsValue::from_f64(vega)); + out +} + +// --------------------------------------------------------------------------- +// American options (Barone-Adesi-Whaley) +// --------------------------------------------------------------------------- + +/// American option price using the Barone-Adesi-Whaley approximation. +#[wasm_bindgen] +pub fn american_price( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, +) -> f64 { + ferro_ta_core::options::american::american_price_baw( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + parse_option_kind(kind), + ) +} + +/// Early exercise premium: `american_price - european_price`. +#[wasm_bindgen] +pub fn early_exercise_premium( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, +) -> f64 { + ferro_ta_core::options::american::early_exercise_premium( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + parse_option_kind(kind), + ) +} + +// --------------------------------------------------------------------------- +// Historical volatility estimators +// --------------------------------------------------------------------------- + +/// Close-to-close realised volatility (rolling). +/// +/// First `window - 1` values are `NaN`. +#[wasm_bindgen] +pub fn close_to_close_vol( + close: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::close_to_close_vol(&to_vec(close), window, trading_days)) +} + +/// Parkinson (high-low) volatility estimator (rolling). +#[wasm_bindgen] +pub fn parkinson_vol( + high: &Float64Array, + low: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::parkinson_vol( + &to_vec(high), + &to_vec(low), + window, + trading_days, + )) +} + +/// Garman-Klass OHLC volatility estimator (rolling). +#[wasm_bindgen] +pub fn garman_klass_vol( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::garman_klass_vol( + &to_vec(open), + &to_vec(high), + &to_vec(low), + &to_vec(close), + window, + trading_days, + )) +} + +/// Rogers-Satchell OHLC volatility estimator (rolling). +#[wasm_bindgen] +pub fn rogers_satchell_vol( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::rogers_satchell_vol( + &to_vec(open), + &to_vec(high), + &to_vec(low), + &to_vec(close), + window, + trading_days, + )) +} + +/// Yang-Zhang OHLC volatility estimator (rolling). +/// +/// Most efficient estimator — handles overnight gaps. +#[wasm_bindgen] +pub fn yang_zhang_vol( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::yang_zhang_vol( + &to_vec(open), + &to_vec(high), + &to_vec(low), + &to_vec(close), + window, + trading_days, + )) +} + +// --------------------------------------------------------------------------- +// Volatility cone +// --------------------------------------------------------------------------- + +/// Volatility cone: percentile distribution of close-to-close vol across windows. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `windows` – `Float64Array` of window sizes (e.g. `[21, 42, 63, 126, 252]`). +/// - `trading_days` – annualisation factor (default 252). +/// +/// # Returns +/// `js_sys::Array` of length `n_windows`, each element an `Array`: +/// `[window, min, p25, median, p75, max]`. +#[wasm_bindgen] +pub fn vol_cone( + close: &Float64Array, + windows: &Float64Array, + trading_days: f64, +) -> Array { + let c = to_vec(close); + let wins = to_usize_vec(windows); + let slices = ferro_ta_core::options::realized_vol::vol_cone(&c, &wins, trading_days); + let out = Array::new(); + for s in slices { + let row = Array::new(); + row.push(&JsValue::from_f64(s.window as f64)); + row.push(&JsValue::from_f64(s.min)); + row.push(&JsValue::from_f64(s.p25)); + row.push(&JsValue::from_f64(s.median)); + row.push(&JsValue::from_f64(s.p75)); + row.push(&JsValue::from_f64(s.max)); + out.push(&row); + } + out +} + +// --------------------------------------------------------------------------- +// Expected move +// --------------------------------------------------------------------------- + +/// Expected move over `days_to_expiry` trading days. +/// +/// Uses log-normal: `spot · e^{±σ√(days/trading_days)} − spot`. +/// +/// # Returns +/// `js_sys::Array` of two f64 values: `[lower_move, upper_move]` (signed). +#[wasm_bindgen] +pub fn expected_move( + spot: f64, + iv: f64, + days_to_expiry: f64, + trading_days_per_year: f64, +) -> Array { + let (lower, upper) = ferro_ta_core::options::surface::expected_move(spot, iv, days_to_expiry, trading_days_per_year); + let out = Array::new(); + out.push(&JsValue::from_f64(lower)); + out.push(&JsValue::from_f64(upper)); + out +} + +// --------------------------------------------------------------------------- +// Strategy payoff / value (Feature 8 — WASM exposure) +// --------------------------------------------------------------------------- + +/// Aggregate strategy payoff over a spot grid at expiry. +/// +/// Instrument codes: `0`=option, `1`=future, `2`=stock. +/// Side codes: `1`=long, `-1`=short. +/// Option type codes: `1`=call, `-1`=put. +/// +/// # Returns +/// `Float64Array` of aggregate P&L per spot grid point. +#[wasm_bindgen] +pub fn strategy_payoff_dense( + spot_grid: &Float64Array, + instruments: &Float64Array, + sides: &Float64Array, + option_types: &Float64Array, + strikes: &Float64Array, + premiums: &Float64Array, + entry_prices: &Float64Array, + quantities: &Float64Array, + multipliers: &Float64Array, +) -> Float64Array { + from_vec(ferro_ta_core::options::payoff::strategy_payoff_dense( + &to_vec(spot_grid), + &to_i64_vec(instruments), + &to_i64_vec(sides), + &to_i64_vec(option_types), + &to_vec(strikes), + &to_vec(premiums), + &to_vec(entry_prices), + &to_vec(quantities), + &to_vec(multipliers), + )) +} + +/// Aggregate BSM Greeks across option and futures/stock legs at a single spot. +/// +/// # Returns +/// `js_sys::Array` of five f64 values: `[delta, gamma, vega, theta, rho]`. +#[wasm_bindgen] +pub fn aggregate_greeks_dense( + spot: f64, + instruments: &Float64Array, + sides: &Float64Array, + option_types: &Float64Array, + strikes: &Float64Array, + volatilities: &Float64Array, + time_to_expiries: &Float64Array, + rates: &Float64Array, + carries: &Float64Array, + quantities: &Float64Array, + multipliers: &Float64Array, +) -> Array { + let (delta, gamma, vega, theta, rho) = ferro_ta_core::options::payoff::aggregate_greeks_dense( + spot, + &to_i64_vec(instruments), + &to_i64_vec(sides), + &to_i64_vec(option_types), + &to_vec(strikes), + &to_vec(volatilities), + &to_vec(time_to_expiries), + &to_vec(rates), + &to_vec(carries), + &to_vec(quantities), + &to_vec(multipliers), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(delta)); + out.push(&JsValue::from_f64(gamma)); + out.push(&JsValue::from_f64(vega)); + out.push(&JsValue::from_f64(theta)); + out.push(&JsValue::from_f64(rho)); + out +} + +/// Current BSM mid-price value of a multi-leg strategy over a spot grid (pre-expiry). +/// +/// Unlike `strategy_payoff_dense`, this uses live BSM pricing for option legs. +/// +/// # Returns +/// `Float64Array` of strategy value (P&L vs premium paid) per spot grid point. +#[wasm_bindgen] +pub fn strategy_value_grid( + spot_grid: &Float64Array, + instruments: &Float64Array, + sides: &Float64Array, + option_types: &Float64Array, + strikes: &Float64Array, + premiums: &Float64Array, + entry_prices: &Float64Array, + quantities: &Float64Array, + multipliers: &Float64Array, + time_to_expiries: &Float64Array, + volatilities: &Float64Array, + rates: &Float64Array, + carries: &Float64Array, +) -> Float64Array { + from_vec(ferro_ta_core::options::payoff::strategy_value_grid( + &to_vec(spot_grid), + &to_i64_vec(instruments), + &to_i64_vec(sides), + &to_i64_vec(option_types), + &to_vec(strikes), + &to_vec(premiums), + &to_vec(entry_prices), + &to_vec(quantities), + &to_vec(multipliers), + &to_vec(time_to_expiries), + &to_vec(volatilities), + &to_vec(rates), + &to_vec(carries), + )) +} + +// --------------------------------------------------------------------------- +// WASM tests (run with `wasm-pack test --node`) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + fn make_arr(v: &[f64]) -> Float64Array { + let arr = Float64Array::new_with_length(v.len() as u32); + arr.copy_from(v); + arr + } + + fn get_finite(arr: &Float64Array) -> Vec { + let mut v = vec![0.0f64; arr.length() as usize]; + arr.copy_to(&mut v); + v.into_iter().filter(|x| x.is_finite()).collect() + } + + // ----------------------------------------------------------------------- + // SMA tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_sma_output_length() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = sma(&close, 3); + assert_eq!(out.length(), 5); + } + + #[wasm_bindgen_test] + fn test_sma_known_value() { + // SMA(3) of [1,2,3,4,5]: first valid at index 2 = (1+2+3)/3 = 2.0 + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = sma(&close, 3); + let vals: Vec = { + let mut v = vec![0.0f64; 5]; + out.copy_to(&mut v); + v + }; + assert!(vals[0].is_nan()); + assert!(vals[1].is_nan()); + assert!((vals[2] - 2.0).abs() < 1e-10); + assert!((vals[3] - 3.0).abs() < 1e-10); + assert!((vals[4] - 4.0).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // EMA tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_ema_output_length() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = ema(&close, 3); + assert_eq!(out.length(), 5); + } + + #[wasm_bindgen_test] + fn test_ema_seed_equals_sma() { + // Seed of EMA(3) at index 2 should equal SMA(3) = 2.0 + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = ema(&close, 3); + let mut vals = vec![0.0f64; 5]; + out.copy_to(&mut vals); + assert!((vals[2] - 2.0).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // BBANDS tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_bbands_returns_three_arrays() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = bbands(&close, 3, 2.0, 2.0); + assert_eq!(out.length(), 3); + } + + #[wasm_bindgen_test] + fn test_bbands_middle_equals_sma() { + let data = [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]; + let close = make_arr(&data); + let bands = bbands(&close, 3, 2.0, 2.0); + + // Middle band should equal SMA(3) + let middle = Float64Array::from(bands.get(1)); + let sma_out = sma(&close, 3); + + let mut m = vec![0.0f64; 7]; + middle.copy_to(&mut m); + let mut s = vec![0.0f64; 7]; + sma_out.copy_to(&mut s); + + for i in 2..7 { + assert!((m[i] - s[i]).abs() < 1e-10, "middle[{i}] != sma[{i}]"); + } + } + + #[wasm_bindgen_test] + fn test_bbands_upper_greater_than_lower() { + let data = [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]; + let close = make_arr(&data); + let bands = bbands(&close, 3, 2.0, 2.0); + let upper = Float64Array::from(bands.get(0)); + let lower = Float64Array::from(bands.get(2)); + let mut u = vec![0.0f64; 7]; + let mut l = vec![0.0f64; 7]; + upper.copy_to(&mut u); + lower.copy_to(&mut l); + for i in 2..7 { + assert!(u[i] >= l[i], "upper[{i}] < lower[{i}]"); + } + } + + // ----------------------------------------------------------------------- + // RSI tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_rsi_output_length() { + let close = make_arr(&[ + 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, + ]); + let out = rsi(&close, 14); + assert_eq!(out.length(), 15); + } + + #[wasm_bindgen_test] + fn test_rsi_range_0_to_100() { + let close = make_arr(&[ + 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, + ]); + let out = rsi(&close, 5); + let finite = get_finite(&out); + for v in finite { + assert!(v >= 0.0 && v <= 100.0, "RSI out of range: {v}"); + } + } + + // ----------------------------------------------------------------------- + // ATR tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_atr_output_length() { + let high = make_arr(&[45.0, 46.0, 47.0, 46.0, 45.0, 44.0, 45.0]); + let low = make_arr(&[43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]); + let close = make_arr(&[44.0, 45.0, 46.0, 45.0, 44.0, 43.0, 44.0]); + let out = atr(&high, &low, &close, 3); + assert_eq!(out.length(), 7); + } + + #[wasm_bindgen_test] + fn test_atr_all_positive() { + let high = make_arr(&[45.0, 46.0, 47.0, 46.0, 45.0, 44.0, 45.0]); + let low = make_arr(&[43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]); + let close = make_arr(&[44.0, 45.0, 46.0, 45.0, 44.0, 43.0, 44.0]); + let out = atr(&high, &low, &close, 3); + let finite = get_finite(&out); + assert!(!finite.is_empty()); + for v in finite { + assert!(v > 0.0, "ATR should be positive, got {v}"); + } + } + + // ----------------------------------------------------------------------- + // OBV tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_obv_output_length() { + let close = make_arr(&[10.0, 11.0, 10.0, 12.0, 11.0]); + let volume = make_arr(&[100.0, 200.0, 150.0, 300.0, 250.0]); + let out = obv(&close, &volume); + assert_eq!(out.length(), 5); + } + + #[wasm_bindgen_test] + fn test_obv_known_values() { + // close: 10 → 11 (up, +200) → 10 (dn, -150) → 12 (up, +300) → 11 (dn, -250) + // OBV starts at 0: 0, 200, 50, 350, 100 + let close = make_arr(&[10.0, 11.0, 10.0, 12.0, 11.0]); + let volume = make_arr(&[100.0, 200.0, 150.0, 300.0, 250.0]); + let out = obv(&close, &volume); + let mut vals = vec![0.0f64; 5]; + out.copy_to(&mut vals); + assert!((vals[0] - 0.0).abs() < 1e-10); + assert!((vals[1] - 200.0).abs() < 1e-10); + assert!((vals[2] - 50.0).abs() < 1e-10); + assert!((vals[3] - 350.0).abs() < 1e-10); + assert!((vals[4] - 100.0).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // MACD tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_macd_returns_three_arrays() { + let data = [ + 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, + 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, + ]; + let close = make_arr(&data); + let out = macd(&close, 3, 5, 2); + assert_eq!(out.length(), 3); + } + + #[wasm_bindgen_test] + fn test_macd_output_length() { + let data: Vec = (1..=30).map(|x| x as f64 * 1.0).collect(); + let close = make_arr(&data); + let out = macd(&close, 3, 5, 2); + let macd_line = Float64Array::from(out.get(0)); + assert_eq!(macd_line.length(), 30); + } + + #[wasm_bindgen_test] + fn test_macd_finite_values_after_warmup() { + // With fastperiod=3, slowperiod=5, signalperiod=2: + // MACD line valid from index 4; signal from index 5. + let data: Vec = (1..=20).map(|x| x as f64).collect(); + let close = make_arr(&data); + let out = macd(&close, 3, 5, 2); + let signal = Float64Array::from(out.get(1)); + let finite = get_finite(&signal); + assert!(!finite.is_empty(), "signal should have finite values"); + } + + #[wasm_bindgen_test] + fn test_macd_histogram_is_macd_minus_signal() { + let data: Vec = (1..=20).map(|x| x as f64).collect(); + let close = make_arr(&data); + let out = macd(&close, 3, 5, 2); + let macd_arr = Float64Array::from(out.get(0)); + let sig_arr = Float64Array::from(out.get(1)); + let hist_arr = Float64Array::from(out.get(2)); + + let n = macd_arr.length() as usize; + let mut m = vec![0.0f64; n]; + let mut s = vec![0.0f64; n]; + let mut h = vec![0.0f64; n]; + macd_arr.copy_to(&mut m); + sig_arr.copy_to(&mut s); + hist_arr.copy_to(&mut h); + + for i in 0..n { + if m[i].is_finite() && s[i].is_finite() { + assert!((h[i] - (m[i] - s[i])).abs() < 1e-10, + "histogram[{i}] != macd[{i}] - signal[{i}]"); + } + } + } + + // ----------------------------------------------------------------------- + // MOM tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_mom_output_length() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + let out = mom(&close, 3); + assert_eq!(out.length(), 7); + } + + #[wasm_bindgen_test] + fn test_mom_known_values() { + // MOM(2) of [1,2,3,4,5]: NaN, NaN, 2.0, 2.0, 2.0 + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = mom(&close, 2); + let mut vals = vec![0.0f64; 5]; + out.copy_to(&mut vals); + assert!(vals[0].is_nan()); + assert!(vals[1].is_nan()); + assert!((vals[2] - 2.0).abs() < 1e-10, "MOM[2] should be 2.0"); + assert!((vals[3] - 2.0).abs() < 1e-10, "MOM[3] should be 2.0"); + assert!((vals[4] - 2.0).abs() < 1e-10, "MOM[4] should be 2.0"); + } + + // ----------------------------------------------------------------------- + // STOCHF tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_stochf_returns_two_arrays() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.0, 10.0, 12.0, 13.0]); + let l = make_arr(&[8.0, 9.0, 10.0, 9.0, 8.0, 10.0, 11.0]); + let c = make_arr(&[9.0, 10.0, 11.0, 10.0, 9.0, 11.0, 12.0]); + let out = stochf(&h, &l, &c, 3, 2); + assert_eq!(out.length(), 2); + } + + #[wasm_bindgen_test] + fn test_stochf_output_length() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.0, 10.0, 12.0, 13.0]); + let l = make_arr(&[8.0, 9.0, 10.0, 9.0, 8.0, 10.0, 11.0]); + let c = make_arr(&[9.0, 10.0, 11.0, 10.0, 9.0, 11.0, 12.0]); + let out = stochf(&h, &l, &c, 3, 2); + let fastk = Float64Array::from(out.get(0)); + assert_eq!(fastk.length(), 7); + } + + #[wasm_bindgen_test] + fn test_stochf_fastk_in_0_to_100() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.0, 10.0, 12.0, 13.0]); + let l = make_arr(&[8.0, 9.0, 10.0, 9.0, 8.0, 10.0, 11.0]); + let c = make_arr(&[9.0, 10.0, 11.0, 10.0, 9.0, 11.0, 12.0]); + let out = stochf(&h, &l, &c, 3, 2); + let fastk = Float64Array::from(out.get(0)); + let finite = get_finite(&fastk); + assert!(!finite.is_empty(), "fastk should have finite values"); + for v in finite { + assert!(v >= 0.0 && v <= 100.0, "fastk value {v} out of [0, 100]"); + } + } + + // ----------------------------------------------------------------------- + // WMA tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_wma_output_length() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = wma(&close, 3); + assert_eq!(out.length(), 5); + } + + #[wasm_bindgen_test] + fn test_wma_known_value() { + // WMA(3) at index 2 = (1*1 + 2*2 + 3*3) / 6 = 14/6 + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = wma(&close, 3); + let mut vals = vec![0.0f64; 5]; + out.copy_to(&mut vals); + assert!(vals[0].is_nan()); + assert!(vals[1].is_nan()); + assert!((vals[2] - (14.0 / 6.0)).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // ADX tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_adx_output_length() { + let h = make_arr(&[10.0, 11.0, 12.0, 13.0, 13.5, 14.0, 14.5, 15.0]); + let l = make_arr(&[9.0, 9.5, 10.5, 11.5, 12.0, 12.5, 13.0, 13.5]); + let c = make_arr(&[9.5, 10.5, 11.5, 12.0, 13.0, 13.5, 14.0, 14.5]); + let out = adx(&h, &l, &c, 3); + assert_eq!(out.length(), 8); + } + + #[wasm_bindgen_test] + fn test_adx_values_in_range() { + let h = make_arr(&[10.0, 11.0, 12.0, 13.0, 13.5, 14.0, 14.5, 15.0]); + let l = make_arr(&[9.0, 9.5, 10.5, 11.5, 12.0, 12.5, 13.0, 13.5]); + let c = make_arr(&[9.5, 10.5, 11.5, 12.0, 13.0, 13.5, 14.0, 14.5]); + let out = adx(&h, &l, &c, 3); + for v in get_finite(&out) { + assert!((0.0..=100.0).contains(&v), "ADX out of range: {v}"); + } + } + + // ----------------------------------------------------------------------- + // MFI tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_mfi_output_length() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.5, 12.5, 13.0, 13.5]); + let l = make_arr(&[9.0, 9.5, 10.5, 10.0, 11.0, 11.5, 12.0]); + let c = make_arr(&[9.5, 10.5, 11.5, 11.0, 12.0, 12.5, 13.0]); + let v = make_arr(&[100.0, 110.0, 120.0, 130.0, 125.0, 140.0, 150.0]); + let out = mfi(&h, &l, &c, &v, 3); + assert_eq!(out.length(), 7); + } + + #[wasm_bindgen_test] + fn test_mfi_values_in_range() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.5, 12.5, 13.0, 13.5]); + let l = make_arr(&[9.0, 9.5, 10.5, 10.0, 11.0, 11.5, 12.0]); + let c = make_arr(&[9.5, 10.5, 11.5, 11.0, 12.0, 12.5, 13.0]); + let v = make_arr(&[100.0, 110.0, 120.0, 130.0, 125.0, 140.0, 150.0]); + let out = mfi(&h, &l, &c, &v, 3); + for val in get_finite(&out) { + assert!((0.0..=100.0).contains(&val), "MFI out of range: {val}"); + } + } +} diff --git a/my_indicators.py b/my_indicators.py new file mode 100644 index 0000000..733ed91 --- /dev/null +++ b/my_indicators.py @@ -0,0 +1,226 @@ +""" +自定义指标库 — MQL5 转换后的 Python 指标 + +所有函数遵循统一规范: + - 输入: np.ndarray (float64), 参数用关键字参数带默认值 + - 输出: np.ndarray (float64), 长度 = 输入长度, warmup 期填 np.nan + - 多值输出: 返回 tuple of np.ndarray + +使用方式: + from my_indicators import cci, williams_r + cci_values = cci(high, low, close, period=20) +""" + +import numpy as np +import raptorbt + + +def typical_price(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> np.ndarray: + return (high + low + close) / 3.0 + + +def cci(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 20) -> np.ndarray: + tp = typical_price(high, low, close) + result = np.full(len(close), np.nan) + for i in range(period - 1, len(close)): + window = tp[i - period + 1 : i + 1] + tp_sma = np.mean(window) + mean_dev = np.mean(np.abs(window - tp_sma)) + result[i] = (tp[i] - tp_sma) / (0.015 * mean_dev) if mean_dev > 1e-10 else 0.0 + return result + + +def williams_r(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray: + result = np.full(len(close), np.nan) + for i in range(period - 1, len(close)): + hh = np.max(high[i - period + 1 : i + 1]) + ll = np.min(low[i - period + 1 : i + 1]) + result[i] = (hh - close[i]) / (hh - ll) * -100.0 if (hh - ll) > 1e-10 else -50.0 + return result + + +def roc(data: np.ndarray, period: int = 12) -> np.ndarray: + result = np.full(len(data), np.nan) + for i in range(period, len(data)): + if abs(data[i - period]) > 1e-10: + result[i] = (data[i] - data[i - period]) / data[i - period] * 100.0 + return result + + +def trix(data: np.ndarray, period: int = 15) -> np.ndarray: + ema1 = raptorbt.ema(data, period) + ema2 = raptorbt.ema(ema1, period) + ema3 = raptorbt.ema(ema2, period) + result = np.full(len(data), np.nan) + for i in range(1, len(data)): + if not np.isnan(ema3[i]) and not np.isnan(ema3[i - 1]) and abs(ema3[i - 1]) > 1e-10: + result[i] = (ema3[i] - ema3[i - 1]) / ema3[i - 1] * 10000.0 + return result + + +def dmi(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14): + n = len(close) + plus_dm = np.zeros(n) + minus_dm = np.zeros(n) + tr = np.zeros(n) + + for i in range(1, n): + up_move = high[i] - high[i - 1] + down_move = low[i - 1] - low[i] + plus_dm[i] = up_move if (up_move > down_move and up_move > 0) else 0 + minus_dm[i] = down_move if (down_move > up_move and down_move > 0) else 0 + + hl = high[i] - low[i] + hc = abs(high[i] - close[i - 1]) + lc = abs(low[i] - close[i - 1]) + tr[i] = max(hl, hc, lc) + + atr_vals = raptorbt.atr(high, low, close, period) + plus_di = np.full(n, np.nan) + minus_di = np.full(n, np.nan) + adx_vals = np.full(n, np.nan) + + smooth_plus_dm = np.zeros(n) + smooth_minus_dm = np.zeros(n) + smooth_tr = np.zeros(n) + + if n > period: + smooth_plus_dm[period] = np.sum(plus_dm[1 : period + 1]) + smooth_minus_dm[period] = np.sum(minus_dm[1 : period + 1]) + smooth_tr[period] = np.sum(tr[1 : period + 1]) + + for i in range(period + 1, n): + smooth_plus_dm[i] = smooth_plus_dm[i - 1] - smooth_plus_dm[i - 1] / period + plus_dm[i] + smooth_minus_dm[i] = smooth_minus_dm[i - 1] - smooth_minus_dm[i - 1] / period + minus_dm[i] + smooth_tr[i] = smooth_tr[i - 1] - smooth_tr[i - 1] / period + tr[i] + + for i in range(period, n): + if smooth_tr[i] > 1e-10: + plus_di[i] = smooth_plus_dm[i] / smooth_tr[i] * 100.0 + minus_di[i] = smooth_minus_dm[i] / smooth_tr[i] * 100.0 + + dx = np.full(n, np.nan) + for i in range(period, n): + di_sum = plus_di[i] + minus_di[i] + if di_sum > 1e-10: + dx[i] = abs(plus_di[i] - minus_di[i]) / di_sum * 100.0 + + if n > 2 * period - 1: + adx_vals[2 * period - 1] = np.mean(dx[period : 2 * period]) + for i in range(2 * period, n): + if not np.isnan(dx[i]) and not np.isnan(adx_vals[i - 1]): + adx_vals[i] = (adx_vals[i - 1] * (period - 1) + dx[i]) / period + + return plus_di, minus_di, adx_vals + + +def ichimoku(high: np.ndarray, low: np.ndarray, close: np.ndarray, + tenkan_period: int = 9, kijun_period: int = 26, senkou_b_period: int = 52): + n = len(close) + tenkan = np.full(n, np.nan) + kijun = np.full(n, np.nan) + senkou_a = np.full(n, np.nan) + senkou_b = np.full(n, np.nan) + + for i in range(tenkan_period - 1, n): + tenkan[i] = (np.max(high[i - tenkan_period + 1 : i + 1]) + np.min(low[i - tenkan_period + 1 : i + 1])) / 2.0 + + for i in range(kijun_period - 1, n): + kijun[i] = (np.max(high[i - kijun_period + 1 : i + 1]) + np.min(low[i - kijun_period + 1 : i + 1])) / 2.0 + + for i in range(kijun_period - 1, n): + if not np.isnan(tenkan[i]) and not np.isnan(kijun[i]): + senkou_a[i] = (tenkan[i] + kijun[i]) / 2.0 + + for i in range(senkou_b_period - 1, n): + senkou_b[i] = (np.max(high[i - senkou_b_period + 1 : i + 1]) + np.min(low[i - senkou_b_period + 1 : i + 1])) / 2.0 + + chikou = close.copy() + + return tenkan, kijun, senkou_a, senkou_b, chikou + + +def parabolic_sar(high: np.ndarray, low: np.ndarray, close: np.ndarray, + step: float = 0.02, max_step: float = 0.2) -> np.ndarray: + n = len(close) + if n < 2: + return np.full(n, np.nan) + + sar = np.full(n, np.nan) + af = step + is_long = close[1] > close[0] + ep = high[1] if is_long else low[1] + sar[1] = low[0] if is_long else high[0] + + for i in range(2, n): + sar[i] = sar[i - 1] + af * (ep - sar[i - 1]) + + if is_long: + sar[i] = min(sar[i], low[i - 1], low[i - 2] if i >= 2 else low[i - 1]) + if low[i] < sar[i]: + is_long = False + sar[i] = ep + af = step + ep = low[i] + else: + if high[i] > ep: + ep = high[i] + af = min(af + step, max_step) + else: + sar[i] = max(sar[i], high[i - 1], high[i - 2] if i >= 2 else high[i - 1]) + if high[i] > sar[i]: + is_long = True + sar[i] = ep + af = step + ep = high[i] + else: + if low[i] < ep: + ep = low[i] + af = min(af + step, max_step) + + return sar + + +def awesome_oscillator(high: np.ndarray, low: np.ndarray, + fast_period: int = 5, slow_period: int = 34) -> np.ndarray: + midpoint = (high + low) / 2.0 + sma_fast = raptorbt.sma(midpoint, fast_period) + sma_slow = raptorbt.sma(midpoint, slow_period) + result = np.full(len(high), np.nan) + for i in range(len(high)): + if not np.isnan(sma_fast[i]) and not np.isnan(sma_slow[i]): + result[i] = sma_fast[i] - sma_slow[i] + return result + + +def mfi(high: np.ndarray, low: np.ndarray, close: np.ndarray, + volume: np.ndarray, period: int = 14) -> np.ndarray: + tp = typical_price(high, low, close) + result = np.full(len(close), np.nan) + for i in range(period, len(close)): + pos_flow = 0.0 + neg_flow = 0.0 + for j in range(i - period + 1, i + 1): + mf = tp[j] * volume[j] + if tp[j] > tp[j - 1]: + pos_flow += mf + elif tp[j] < tp[j - 1]: + neg_flow += mf + if neg_flow > 1e-10: + result[i] = 100.0 - 100.0 / (1.0 + pos_flow / neg_flow) + else: + result[i] = 100.0 + return result + + +def obv(close: np.ndarray, volume: np.ndarray) -> np.ndarray: + result = np.zeros(len(close)) + result[0] = volume[0] + for i in range(1, len(close)): + if close[i] > close[i - 1]: + result[i] = result[i - 1] + volume[i] + elif close[i] < close[i - 1]: + result[i] = result[i - 1] - volume[i] + else: + result[i] = result[i - 1] + return result \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6cde2b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["maturin>=1.4,<2.0"] +build-backend = "maturin" + +[project] +name = "raptorbt" +version = "0.4.1" +description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint." +readme = "README.md" +requires-python = ">=3.10" +license = {file = "LICENSE"} +authors = [ + {name = "Alphabench", email = "contact@alphabench.in"} +] +keywords = [ + "backtesting", + "trading", + "quantitative-finance", + "algorithmic-trading", + "rust", + "high-performance", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Financial and Insurance Industry", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Office/Business :: Financial :: Investment", + "Topic :: Scientific/Engineering :: Information Analysis", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://www.alphabench.in/raptorbt" +Repository = "https://github.com/alphabench/raptorbt" +Documentation = "https://www.alphabench.in/raptorbt" +"Bug Tracker" = "https://github.com/alphabench/raptorbt/issues" + +[tool.maturin] +features = ["pyo3/extension-module"] +python-source = "python" +module-name = "raptorbt._raptorbt" diff --git a/python/raptorbt/__init__.py b/python/raptorbt/__init__.py new file mode 100644 index 0000000..438ab1d --- /dev/null +++ b/python/raptorbt/__init__.py @@ -0,0 +1,257 @@ +""" +RaptorBT - High-performance Rust backtesting engine. + +Provides Python bindings for a Rust-based backtesting engine built for +production quantitative trading: +- Sub-millisecond execution on thousands of bars +- Disk footprint: <10MB, startup latency: <10ms +- 100% deterministic execution (no JIT cache) +- Native parallelism via Rayon + explicit SIMD +- Full tick-level simulation (no bar resampling required) +- 80+ technical indicators from ferro-ta (P0 batch: PPO, APO, ADOSC, + OBV, CMO, ARONOSC, BOP, ULTOSC, and more) +""" + +from raptorbt._raptorbt import ( + # Config classes + PyBacktestConfig, + PyInstrumentConfig, + PyStopConfig, + PyTargetConfig, + # Result classes + PyBacktestResult, + PyBacktestMetrics, + PyTrade, + # Backtest functions + run_single_backtest, + run_basket_backtest, + run_options_backtest, + run_pairs_backtest, + run_multi_backtest, + run_spread_backtest, + run_tick_backtest, + # Batch backtest + PyBatchSpreadItem, + batch_spread_backtest, + # Monte Carlo simulation + simulate_portfolio_mc, + # Tick signal functions + compute_tick_entry_signals, + compute_tick_exit_signals, + # Tick feature functions + tick_spread_pct, + buy_sell_imbalance_delta, + return_window, + realized_vol_rolling, + oi_position_pct, + tick_velocity, + # Indicator functions + sma, + ema, + rsi, + macd, + stochastic, + atr, + bollinger_bands, + adx, + vwap, + supertrend, + rolling_min, + rolling_max, + # ferro-ta indicator functions + cci, + willr, + sar, + plus_di, + minus_di, + adx_all, + adxr, + roc, + mfi, + wma, + dema, + tema, + kama, + stochrsi, + aroon, + trix, + natr, + trange, + stddev, + var, + linearreg, + linearreg_slope, + linearreg_intercept, + linearreg_angle, + tsf, + beta, + correl, + ad, + adosc, + obv, + mom, + ppo, + cmo, + aroonosc, + bop, + ultosc, + typprice, + medprice, + avgprice, + wclprice, + midpoint, + midprice, + t3, + trima, + apo, + # P0 batch + vwma, + donchian, + choppiness_index, + hull_ma, + chandelier_exit, + ichimoku, + pivot_points, + # Hilbert Transform (cycle) + ht_trendline, + ht_dcperiod, + ht_dcphase, + ht_phasor, + ht_sine, + ht_trendmode, + # Market regime detection + regime_adx, + regime_combined, + detect_breaks_cusum, + rolling_variance_break, + # Portfolio / cross-series tools + rolling_beta, + drawdown_series, + zscore_series, + relative_strength, + spread, + ratio, +) + +__version__ = "0.4.1" + +__all__ = [ + # Config classes + "PyBacktestConfig", + "PyInstrumentConfig", + "PyStopConfig", + "PyTargetConfig", + # Result classes + "PyBacktestResult", + "PyBacktestMetrics", + "PyTrade", + # Backtest functions + "run_single_backtest", + "run_basket_backtest", + "run_options_backtest", + "run_pairs_backtest", + "run_multi_backtest", + "run_spread_backtest", + "run_tick_backtest", + # Batch backtest + "PyBatchSpreadItem", + "batch_spread_backtest", + # Monte Carlo simulation + "simulate_portfolio_mc", + # Tick signal functions + "compute_tick_entry_signals", + "compute_tick_exit_signals", + # Tick feature functions + "tick_spread_pct", + "buy_sell_imbalance_delta", + "return_window", + "realized_vol_rolling", + "oi_position_pct", + "tick_velocity", + # Indicator functions + "sma", + "ema", + "rsi", + "macd", + "stochastic", + "atr", + "bollinger_bands", + "adx", + "vwap", + "supertrend", + "rolling_min", + "rolling_max", + # ferro-ta indicator functions + "cci", + "willr", + "sar", + "plus_di", + "minus_di", + "adx_all", + "adxr", + "roc", + "mfi", + "wma", + "dema", + "tema", + "kama", + "stochrsi", + "aroon", + "trix", + "natr", + "trange", + "stddev", + "var", + "linearreg", + "linearreg_slope", + "linearreg_intercept", + "linearreg_angle", + "tsf", + "beta", + "correl", + "ad", + "adosc", + "obv", + "mom", + "ppo", + "cmo", + "aroonosc", + "bop", + "ultosc", + "typprice", + "medprice", + "avgprice", + "wclprice", + "midpoint", + "midprice", + "t3", + "trima", + "apo", + # P0 batch + "vwma", + "donchian", + "choppiness_index", + "hull_ma", + "chandelier_exit", + "ichimoku", + "pivot_points", + # Hilbert Transform (cycle) + "ht_trendline", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendmode", + # Market regime detection + "regime_adx", + "regime_combined", + "detect_breaks_cusum", + "rolling_variance_break", + # Portfolio / cross-series tools + "rolling_beta", + "drawdown_series", + "zscore_series", + "relative_strength", + "spread", + "ratio", +] \ No newline at end of file diff --git a/python/raptorbt/__pycache__/__init__.cpython-312.pyc b/python/raptorbt/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e22b463124ac0aa854de14c889c8d7edacf32ed4 GIT binary patch literal 3157 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 z zQBM|TdZNu@K9+sS)WPWf)US7p*LhiYc`2eP(X}oBL z?%$11(b1faS@7&7K7MMfV6)623lWb|LGj?xq+nPN)MG`FJet$g=n1!9Wy;7Gb#)E3Rzi@tzBBe4L*d0`fQE*XZOpXzvH4_VliC)=(( z*{bq5M;0LoD+|h#whF>qN@Z5V|c4);RPD2-m3Q*0lhUsiId@P@sGGJ}}8AaT<5}(XW$(*ij3W@EI+7}L> zaa?)h6?43xrx~SqX0rC2J7xYC*UU~z>RVNEcK&m{(KM9|MGjd*H?n1%6k48~yC!!- z~YrVJFAM1Hgi2*3}Tv=fBkLjZ|XK@Y}+~nuGgARmhmz*-IcX~v17N`~M_3kEiQoLP%B=`s^g{^v zR}*L;#7=GLpZUr(*&W4jVg|~yjuMm;mwN!dC z>A(QyHht#W^@iKLJ=1BoQ`d`|Qc1_7(D+C*K9n{#vJNJ~~EFsP}=oz_1F;8Idow7wcSu(e~8AlvZs;yM<+X*>!GnP@lqJ zL7y^uC1um$kf5=hf!xra>f=MDqMh{)VtZa~rLC~r^H8rtj2OmRvKZ_?jDM&e3z>6TS~^oq6?5SInMywVu05UwOss zYi_#cXSdwo&aKf`&bh{W%avDO<8|k*E->EmZ|mbRHpfRE*nAGI^InMC+Q`n>V83K{ z?kjro7QJd!|H9<$dhVC*s@2ZI+^_VinhbaH77ya8R;76)KHbeoyvtqZM&Hq$7n)t^ z4qmt%uEw1|UFi;v4DCQQxunkjO$u1kDILR{hG6z#w%0l)*e_`%c!OB6NHI5LRQcB= z=dGWUfoeyBJjqoB+H(UOX1|w_D9QcWw!*&ab$?y2y!tsOx|2h8vN33GlhIrnRqods z3j0>M%~9wGNjI?3nec!fxA#)B-y6iW5(<-~zt-UCTZc*KJ|#LN1jflUcqi9jzFo$1 zzasAtKPqG~2<2a6KZR5EEn($YA>b{nr3uEhVdTGR>oBm+FZ8~3D#tF0y2d?!r;r|_ z)h;BK5a>lANeLxyj~0?m>%W^mjvK;B_ZT~wp`N?NlZ%{oLOWUGp-60MP$~4PkMrQ% zRJX9?4Y1@JP`T86p=aJ{1^wR7z{w`OP&d+pTqbwD5Or8LBs#3S zIh+}tW*$|J&o|O!h`G>>QHvQtlVMLb!$C#Fr7IDks?(^@4zw3PsY6RoE25>1s>eIp z7JM!K8!ZSN%TX5MXBe(JW~Nc!j1tzFv6D!832hgP*2uA4A zUI@{z8C5o5fU9hLKN$0wYD}S}ODiLW)C;3tT-t47h&|c$g+}QfzHFgUkWz-4G7c2c zb1g?JyU`=gEX^mQ7Bi|K1riZM!l>npnvw#UsC5yDqTPWT8=)uS zqvX;~?YY2qeqjU-g|P_+w&B4D91K`3!%GY3G(xojCj$P#b2$u4jnh_eV|8@&q3L`W zP3{zilBKPvLmv3TLcqV8fP>${59!NKFx76qKuozxT8tOe@|wUrCe@{-1mFYOBE$gg z5i6$g>)@gcR4cj#r33ISN-*m){G@+|wvHaR+ZxGC_E@Gb@Ffh-fcce zrVCAsTjqu|=iSXHd#GPN8gu8!W~fJ6elx~dd+GD5Szpm?X6SiUC2#Cpg|XUN=n^Qg z2MK@mWFHXlGW$<~CcFuko-Qt!J!9yUg&Wk7#grg?g<@&|{4k|E*`hmWg&2(rev-u% z(=+Wf!OhN#c~yzixJXyM~4OWpzI(i7gwNxKd8V;*64VU8nOgKsl;1Xh>O$$u3!*O<) zZ-+T{m|=%bJ4`}I4Mo5|n#dJpco3jmy`&S&KjL4AD25!`V#sZ}4D0N0vmIJ?*o+YP z=}v<|Nh*VzaLyB==z=2^R|G{7+%@IX8yicbc1sM56IAPF>M1fP6QB`+bTbf0x8GQiCGRU#GNo%5KZL;t(_T%vwOG1x6f^7_ z%ZKg6K`@7#8eF?xFEsCTHacCf8IWXy3pOY65ASqH@q?1%rk69Rba)!v^K01QaZ85> zjSa-N4WCndue27Q+yCN0N_@ZfU=S}@4DlTnrPysWMbbaOtWx8By3s5>BNGi@>O<5+ ze?ya=jGe)e&$ZH>?~?&5O=;3EvMhL_<7y$W=U zs6%EiDms1+QMh_;%=oh1Qu@XlUQmMNfcV3s@Y71Pa`at^asOnz~d__iS^^XpJtz%m13!XOo0 zGblSUwC5?mu{JLO6RHy|*5{&ZvWh<0dJ8?y0_|CuA@rE(KUj8fQe&n&Z%k*1HmCW2cmnbWPHNVk9dNKa z`85s;xFhRI8pd8V;66|ChR8N|-rL#()r|`|;mAadzbJ(NQBmD^j}(gCU-=;8rW8(u+VCU;8@|ND?>_ zfn1Q)jzA835&}88l|UBpJ4&K3ufcmsD@jE8Kk4|r@NaYb2duQkz%%&&tu2v>dB$rNgc2kZWwgPQt5V8S;O00CIMG%28zOaq-bWrR$5(&u?k@C z!>^nuIY{-wdL%YZZYg}+wiP`(uk8+g&mU^;k%4>$Nb6n_`(4uu(i`; zY~}EPcK503)h-p`(C&U2vT_^sZp`jLGu{3MOzCXJG9N^v>2YP)VZI$sQ{j9cL;UHr z^A`&sU?>hY#6j_<@rbk;f>*%&I5;g1`r_c?I2ek94N-7@mF;?duLV44Vs};f}(r5?1}7PawY?AbV-x_Jl(_92-xszp7pi755Ov5OA<;6dY1#yqjAh$=ESoW zI8=~fo%wG7*fwT|<$aV@s+QF$aAIo0iz&xI32Kj5#+EvWL02jR7H&|_;NsF@hM&w{ zi+EFPpw*@i*%tE`$?t8rOH_KT^g2AV6~+iNMIO#Bwtklzg137X2Hn%VwU|f? zrLa%(38wROGv!sTP70JEJ-El(7GUI95Ab`8T1H7Jg@20#XdRiqEOPq04O^(G;eRkU zwS~&wq;rBFT(QmIpAX;AB@S=g$eu#`9*Oq|clPQ?x|=)s>4p><4UzPtp2E<5mh_5H z(yCDH$LD2o)32gofGL2nyH^}A?6MzgSo2>-D!%!?Efm$ zoA%cg?4x2xfI4SA&X&M+9OYq3N7GDwtBc5AP{`HSEs&2P@(+dxa&{MVGxfutsA(|d z4ZoMh7P?4~s?V~B4FupBl=mNx^r}eElL#S2l=3i*=)FKLRZX^bS&pN?r`j@+FA3!NrB zBRM;5x|x3JnX+(=k@oozy^DdO&n8g^Td3ui?3JPx>QKz|Z3^{)V}bmPF3{I_b^?8< zY)RHtGlAm4H$Wf8$YXWUuGMW+Nj-Zl`@1=)&kj`ek{$9Cv!7@E#x@#1$Y48G6MJzK zUl;*aT=7-<;yv6v4r1Dl0%eRRlpghle*o(M@(EylJa$|h%t-)&n}0}~j{@MI$4q&b zq1*gL69N#;=@1J_Y`4YeKseZyNtM@zY0;)&`k4zm&>7-ivOf4tRJUe)Smv8`L)%W{@oK=^2c9A#*vfVeFdS7gyERRDg7_@abY^1K$JzM!i0N8$Xja za8Wp|E5c?LZ!=$o+#E_m+B$wx8z_qlahxqgc*?ZvSl4V+LWNOC;taWsuRVENbpOVr z!n}7i|E(Mhs|}IN+%>S9jYrt)DP;&swb)n};~mUdJ^16LjYooN->@X_7I$#`p~ddJ zH)g*co{gz~&+uh*qcs1ioaqnB8r+e-A7!CmA_K5mz%7zl(L`d5U88e9@E8p!zEr)p zOv!NhdV>Fs#W#rpMRpU z&Z@?U=#SugBbt({{*RjwOykK$E z79D(a3gRAeB40gkqvoFuiVBPs)2KG2@H#w$E(hNS`@sz|i%^so22(7Q^E7}ED%%h| z6|F#ESFk3Q;Gj2?0PNecO<2&7|E!Dxv1+=nUpEi7LJ%4Q z8~_EIG2S6Ca|MiXoeJs|VQ8J#k-=sIqN%P)hLpy?>@jX|WKr%{J8~F|cMQWkwbzl) zhbayn4+V~Kz(SUYr3kR_1b7+~c^oqk15V1uJBn)x9JB0&ZR7*zS)}$`jy;2@4{syC z(^g@4W}Gl2J2-AmN9mo0Lx9xE4%4Ldn5#iE95~|w=Ut@1(j|l3$vaq_I6tEZuDr=g zqerSxvFccS^?ZjRU5V#`80&EyNV@kg+^aYx{dNhnaM(xsTlk)beyj&wuYvX=)=~L- z15jMHYPvR{4*#)KX!3d>o=JD8s9KK%C_=!>HHsNcsU1M$6rBx!1gmA&UE6xY2`vVx zb_GU9s)FM019!Rf;vWj8a(<4%7^i6E#v>Rxu6f5u&6)BsN{z`O(Ui3iof<~h^Zrp% zA4j{RLePpz5v>zweU-G1KvW^d58L2TjZ01i-J}F%A*qw=^x-CLJ1i;&8)(X5$4jfU z(kJjS=Lc;?#nfyi&1aGq%lg*J8v5Q9OwNgGqfTgL%}q;z*wF_IWfa15we)O^#_#@4 zyZ0H=qK2aM-X={obY*@LRtu<)M0iPaA&*?RLuJC7f#MXyO0w$ZttxrG zaf*z}5Ti|O6!CQJ@cBFn0nY|;;khqVIW46Ze?7gNx{XGL{;NxBAse<>BolTw&Y$Y`%fxl`X1kY9_Hur#~xAxQnpv0L6_Q)_{ zU$O}^4<#v$$F&+iT1dfR$j(L$mp3KZ&bJTpVB1Me9(o$iXqj^eM*w7HYU89Bh%cLv z3PiHVVKIx&;}M=ETxq^~eG*@*1>{ma?!#N~ zIuMnZ8cP}T)6z-+h+m2<&@1F_c+~Oi3MaVaxZZNaBcj+U!0+d${_fATCwB$djxoCK zGe-AcglLpF2jXpOpSv?G=SUt`scgT=jInlP<{&PGnz;%;-fDcisR1Lx-nkh4XaGu% z`eMC8vwoa38F1j-yD;(J(A!vw zfpM(s2aLY@MKS`7*>Da)+vSO(W@aV#W35gIG2z)U#vR&aP1Cgr>!xcHaj;X?7m4`r z$<(j#d3kb4CiK8LaUo0hwYf*|1~RDsQ>@$J za01#Q_65;OYT5a4Px7;%9f-9@eRrd{MC6zOrO2o12Vr`Y-HV7?!pimf_MFd1yaWAg z-Z7vy4S^fWMLdU?GnfyYE54A$6QoC0zsR~iJsFlU)Zc?DTvg?CS7AyqcfFpBEk~g= zH%x7AJ>t_nxfqeJ#tM--ef|y(i4y@n!`Jqg_)0!buI5+x*#45A;p^^5Z&O8jbYqiV zRSQyU_1stW+)dK&x42{^)MCjQQAx1M!V#gTw8w?!}Mh{Ei<0H^1PaT($V3 zs__?bk~p&uk{XYQvY0^2e5zC&Z!21k#4Ay3WI2GINt~nvT2si;-124sdX;ZGslKaSj>|cwl2TznZ2NL$QTt?a#gK5WhX{E zWjA7_vFemT;meUDCNuhyq36Smc!f`EMY5kmesw^4IZoN5`#J@}go0|5)0h2D+8_4j znNoF%b{AraOX=D#P|2V}<3H!&a@F_(-6t6b5S-F|+8zEKQmJy=v#49vC++UusF~q3 z{9p|pM%iS#c{Dnz*nb0K$jZ7TgGMb_nSp1t0i2KW1eeKJgV^I}pAnenfn?8c8F|o3 z4`HKB)4c_3V`q$eeu$+n9Y)PLPx^~!1N0Uh#wxe*x;t3TuA-h-LGfw+_2?JVcURp- zchJY~B9t5?#ly`F7}Wk3;o>;}&AbfUFe;^A|^ZkadT$2dNf;1NJo3`-FOz*YzSk9ztg!)1aH znj@pViM`4n*P&PW@$X~3O8xp**{c+cs#i&L?Dt^;?e-43lP^nml8f$G4E!Jq75R46 zshomNrSuf@8C;#vs}$bsUZpSxkCu$RsxO&Vpgx9;Om$z@9V2nqMvebg>n-C^&sWtE6^45emJqh!#X z!1nNKXd6+SWLMaPRs#1VbnMhbTmT#17tN4TUO`s0(pjh}=$;hpLWkh{lsV_*t`RE% z8#Lvgcu~E9ZW>JR_{JUD!C$Z{yQ{;Qt%b(FS_o$Y5~$8#&Xp>Q8%h5W*Lo@TIO5z#pB66cPG#i(mqgluh82 z*U`uD9szrXssxXuIc3gKpcK0s=0kKSm80KA+rve&;n~vhKwGf&JGWWJ{xoIjF+dco zjOP5qLZC%Bs|WB1$JY@G(f=A*j2@6sNs_9U;zt9cw$ z{y;eBle+;NG~VIHB)w2uuBiR|gF;gKIcNYD&H1!V@vWrzX`AA6LEu*;5TsOpCuC!F z94UqK^j%3E2z(cmzE4WuC#Bm!X-S&PSmpA+kEXvl*~NMN@rBo~2hr8|2t-eMn~Ma{ z=pPA!S9y}(;+d}g>UsS1Lw-|oB)KZmLDi|(F5+3Om%j@Z!!wYtjT(=NAuQ{Y?bJwn z^a!NQMbE+cvoidgj#B0EsyLB#5{g2|AHNHl@GUd}3WkGYwd!}lQx=3h;UlD^#-Ugy zzJv7`SOr+i(QJ3#7bVN2Hert5`(w}bTjtSQym}xQ!C)iKrUfSCDPkHAIZXHx7{12K zEu%{}c#k0uOMb(1e&J5pv+v@2gzpFTmQjV~!jMw5Q)jDBfadr<03{E|AmrVs3%zlxmV zl-`nGFHM$effJaoU#l2(kkO%c|C&UM$5FXO;7#QD5kxm0!kzFT80J~KpFja_`hq(v z`mbVgmv$xV>WCWob_ztFg=Fd5fnz(9HrXlU4GJV3XXY01++38!r4yyP*$vsr+tnSeTN837ypZ;bw0iV&dG50CXYCm58Bkv8xLmQg5U^9=*AGFKeYyN zJ0Z~1YDxy(dL$q4UGRe>Svy7*H(U=Bizag_jAC?4EU(%dbiS8&qNUNNN5K|Au2>8dSh4!nZcDT|G>+Eo| z9a?tSj1c3V9Gk}1_2~Y>0J6n`^V5JQwEY!aw25$;gCW}e3a;iv#EO9kk$(mP{vpcD zQc*eas3pSykzCv$X0&|`O6+XTM&Lw38Fn=qHK#dhL&u7Saj~G0XHJru@?uf$L zs1O1~$|_D{g~cc34Ffx8tMQJDH*x;?ui#SKC+Xy%osFcIEkF!9n`{laR&ZfLie_*f zNuRD#Bt=uqO-R8J-bi|ZO2IWaf@6F_iX_Qp2vfjdAcJ!fUXGKOseBn6aSUF>dNvJM zwfX7&h=_J<3UP|(#_)X1DN2nDe~smZFx4TBTZyx%W>;knIFf$w14L=hr_PkkiMnxt zqvirhGO}BRubk=|D+ z!+sV0cx5a)909%?5N*vp8wt3%*SdjF5uqyyT|}sePywNf2%Sr)fKU#ha|xY5D2LD> zLMIS9jL;xL4nl_!`W_u_P=6?B!^s8K&q%??DsBh}W86;|w^7ASl(?5NlC1n;AQ*b!;_bxsI#_-X|G*L_(}!c#yF? z3@e&eVttHl1X034_$sWIQP4cBTac9D@MesLnW>k(06C~J0_zM`gB4`Xj)#1XVcerC z?uvx82Ql6t+eYO|l!MA1i;Pl{b>kc?1Xzw(yj+_2(rU)biLi*Yoh{b&h=s-ZL+BPk zO=R&!t(*iRqK%#-q!ztbU5gXncKzPwge#lMfftwPw3siBoqrxbWXnO^`~rJ0KXY1uDkE@zB(*IzS30AR@3 zzZE0=s!2BNdy>S?F8VoFAE}gDmiiPvkirE(p$DLH1ON)~3v%!a+VBfP@yq*Z=C39z ztqu!{p7;JA;P*Xn*OK35!T&hFceNR7nfV1~3;LpHb zlwl5KMABcpuZJ0?b|X<%%*{r5;KD2uzC@WUIjQM$d~Nem>lPf`5)B zNd^B5tVYY1b#8TETSxup*l>Z}o$T~*!Jh7PB>fL}#5)nH6M6IP zJ?&BMvp<=@Foe*$`@F~SNL&0q5ALF!rfJaZNcz3Eajv1uU5R}$U06Gi>6A-xJ1Sq8 zKzlr>8~9xV?xOsvPU1ti{$JpiLfEGQ-VFAl3>QlQKXJ=`W0<2OUFt9Xd+k$vc&M%M zKd>DY1bvc7`XU%{EGB3<0LbRI+xyJX^UwEP-}k-z!DN(eWze3u`Mwym%S+KjB^hdex>~#nRmeOEA8(k!wwjJrTx9~oCAhmX@6&* zeZcT5?eE<~4;X%>{atX@0mHAfzmJ`X<1+2O;e&wpVSsPn)(ZT`$A5~kEln+tnFlD6 zUiUM5JVchqJUrcjJ-xifYCnwx{jZb9N=QJ)heRItT|;^BHnRUld9*n`B>v0YIs36r z?DKekSJy+f$*=gQ3r-h%AiUoL_`lO0w8_8tZwpS_k39cp%Uk@n$4)(9_?7Z~`ji8P zU-4I~Pd;Gy6@A`t(gDM-*ywH9Vy{KFHv&F@>~$H(Q?_O8N1p$)_FC%m4jhZS-^%zu z%dgbuUIz}p<<#fHPB>u7SL*MO;|~~q#YUfzDfU`)`vk!ItuF7^{$-o~wb-vSj@yqs z|7Y!&)aQ{y4j6u=K3_8UfZ^R@(?=dK{7N0J{-M-oDeGqe|KF(33G2DBzO^g+ z`|a)Dk@Ve#@#U>a=vhV5Yd!yK*Xwu7_`T@&h9mZ48UHV^U)v5B{TALg0{*|D-`!W< z+tT+V&-<;s_gt?Zl=C`p=pjp5&-)e!pwXsz2k6)TV}_pmh4sS~xVkU03a?5&iEdKq zp$$m+m>2DEe+sr_U^~Csoq}IE5QJx0sI40Kjh-7~A7p_TwX-0~w-!IeqDiKyaU4{Z zJ8sa(dL(`41qaf4{k24LV*|K;V6(r#7$qyAgV zKWYbK=QU~1F5+4o9NWZRs+{UFuplmvVzGzYMwRxAV*NncN) z@$@TM=JUAAq2zTQQr84V>_3_OTjSH+&|g>s&ZE_99N3PeKZG;#deoChxG$??f1*e9 zfn}>bTgifkT7+V1Q73Bg0+^4h#m~=oB<1@)O3p8wfiMD@STpQCD>JBbz)gm-9>LyMxLcfAwqFTet^&;t4oQIg8T$hQn@#I`R+?E5G z@}xW*uNUi}M4eNR((%61ec3?G+SFxvK_==%i7tvOZf&A&D=uE;fr3#%(b`ZFk@Tx5 zotU*5I)YDfB;o!MOZ5Kw&e`AYlSiT;cmQK0{iniu)a;(}qlA{cXBFe$JU^i`)n^ZV zh@@W+2BY;P2PPwuo;Q4dv8)}7tUK(l^@x3?WrTgDnZ)JjD`+=SA)GNgJ5cQGB@jcr zLS&wcD@uIZlMBjLj7ECfzDM_(oh~?EU%h_sAyX zN1iD^3ISmyexo%`hy{!C zUdWEV=dg{HgZFanQXW1J=ktb#z4ZrBuexbGDJa+Vz!l;7&*&@pcqY~Nf9l~gd-flH z_9@oCDQ=F`1jazzJ*yo1%RaX0kkSNNLzeb%*>%BR*mwKi27m1h_zu*L%}O*((Pd`ZUkCKk^Pk#gapZed< ze#*`ayXXo#Im7=zy z%iM2nz3&r#oz&}HR{;(NGH@RZI%FX%&-c61rZs$@vh_(I6%0y0fu2f zBz;hpZ5#`Yrbzl{C-7-eT!KB_=|gTEjweD!G4lv`-`9WmDya}jC5MA~vZt&fHO9IY z+7lZz@4RQcPm>9qY!Bl4lF%S9a<2-LU=O1XaWJ5x!th>AT>Yv~>rspFiX1gNaneup z`Jd4UVj>=nx~K!SVfc#$_Q=7ch?W0gf3bun5!iURkeg;c-`GV!!n1?4L6 zsh}7EES&^~Co@Yo3NkbF+Jej+-1&<)=?ni1EjG;=9Fh6t)8>f3k{}1%aF{fyFvbkj zoPlD1TG|+gUdzkOlfv)t748aG=;iAzH}y`kzt&1|Nf>VF3-E>JH0LqM4&+N z=H1Sfj8^(cC!}#S*z}D!l+21Ws#s1jX^<3=#;5m06Qs5+u2+}d88a9wc%)0cEk1-xTAE`A)7gN&@1pYS4Y zlk8bnDVPOsO#&Rwf>#qKY?vZ!Aqc}YZG&dUYPjop+*z4qJ%H2Fkvc&sO2)<%qD2Kn zhD2uVXq3!_knIW}drHWlsPO-%OH>K!&1hIaMgIK`Z?$)!(+p7vT(r zXq_!(gA9^pAbp4S^bVA3k>D1ytuuYxY@;}tqjj7jM_4&R!NSTR3Kmu(;dqgMsbFE{ zBLxd9ZvzfL3F7~b|4EGp1uF}fcd%k*l%W?A7%IFWfuZL**Fb2ccLM{il(kz4X*{&F zi=4Q5HP%c-+43mTgspr93tML?SlG%^u$0gs1q)kF1q)j#fWvR7e1{%;oFl2x0SZ>= zinmadx77)}xiFU$E384C!BeRE8;7q;iUaMJgEz7O4zSut>!LIBc(<@sWQc;6wnziy34Gk{D^4 z6cOB~UW;&@sA)3NwG!=6lGW!Ow2mj>_FMEYBoXc>jZ4PLO4{Ap_TC2M^N75q^AqKA z?)EsB*C|-IoT^~q@)89LmtzzxT%N07;qo*E3ztIxhu4w?BhXChf)$$?)Jiiir5r2C zd?d4;`kp4TVg^4^Er)JYSp3BQ4dxsV1=KDVl0jgNH`V&nhg=@{dmurlOqrsE67_@~ zmBqXQ61BvR5?*IZ)bH#lVfk{2dfbi@&OH+Km>neo$djl??I@AM35Y`OB8MXd7N+6z zYXKVK@Y7@~>iu?D`g5;KD19`+Nmk#tQ2L9$rpsQCIS*KbdBDXUbeCO*f`w z7QudbGshuLN1_bS#}D!WQv`&E0VeN+a{!ZR!dZaHE#VB^ETjiWUT_OG+4I;gg73C( zBDtugqaE7(7g=kAqn(;_^gt?^y65#N#@cPokca3dCVB)~zyU5|Woo;LN>XO(kiW#; z#3z=Cn|RKjTDghq?t>^iq_k7AycP$)#cGnsvT`#JZ2gJ`s?cHG^#~YRBb@^SHY!?X z-adMy%qI+r&0#T?p0^Vpq5BpdO2Fpvya)H>@i|~`kH_==9Otq6N#XG)e{99$#|B88 z(P@1#aGYI?NBAa5)|;Dv;1KGIP36LhJ*hmlgOXSX>D#K%e~_Yb*&kX_SwJeQ6N>xH zHZpm;15uorkjZmW&i(b)nY>qqdwZuv*;tQ0LhF9VLv7>`?RTsP=}|T&s2sL0ZNI6xPlQ(lCgs_0x)G2k`@^AtIj)qvL5y2uVRHqP}`$z%;al1c#0|;`_ zGIS2X<_B9Nc#;TS=`@0hg{=`>PXv!P^JeyA3ur>3<4I}|Vxg$WAAJb86^J3qQ|y15 zLoIlbxcRG@GS(w%V}5F{_^SGJFTPpbjBju^<6D1F_wqZxoACK6*@!23PmZ?7jxDh! znuA|$mA7iaWwf)c*KN&bx33TWO16+3VGNpttb5;;=9x{Rb(@04E10QZaR;tau=oL! z6wD^k8mnMw`OjCdH2G&LSepD30k_9lyX!65r$1;4b?e{h0BdY~xfAux#y7v4@cAm~ zZK?7VLbHIlCLsP+ds0xZ+ZR$B`!PF`kCfz7Z^xb3B?^|3{2v8NNtz0llANz#DajHA zOG#dlA=)Z5rP~*S`!fGvpwAEb-P)2P5-e}k(zt|TkZ4GCHrc- z)vj*B=c}YBlzgO+tT!ni@x_mUWU=9P~nt*hG+v8uoZeKiI)E`mXsyDhGGvU5Sqg3~L`;50&0A2GBM7~#akWCl$S@5NGA3mw(Rj;)#hAz^V0RdaHfDa;; zvb&}u+->ha>&QP5deb_>*|qW!dsDTc+xVAiJgb}W4emC4dkn<1r{#(J4fa|d^VfHq z{6uGRyAPjK^R4yWMxWrjzx(iszPP#%pR`Y3yxP6|KH7cwq`qC;efY#4CU+k`k>6A6 zx=ns!ude7me4;OD-G@)gqw1gC#;=setnR}n^=)wX;gj-M|4O&17N4UFQ z-cmkQtGbPUp>Y=QfnLFVO-H!*k>0cpaJ~}#ll9uJ39j*~`znXc#MW$AIx(S-rQ1nd zb66W_#;8Sn>2^8aO$v3iN*nK{aUnsiEJA^c!ns$8X}NA2i%Rh(z36UP#ki7#BWH!U z%!;5|w_Oj&Ubh_rh$SDf1=S~EWd#HzEru}N}0kB^hAdCQt3!GPjFAkxdWSF@T* ztTG?&Z?;d307d!4$W`)Lk}yHqSh+n5MEFKrNy22U^2MYrwpN+M$XaEWP#(CAsB8)S za*nK^APLdZ30#T?ELV|YNwcL}$@&3ye;JIgp!mUkvRvhN_sR+iCQvK4hyM)o|98uJ zXPa!byve5AUfdRpuCS*RtW7xJvb8|QG0R|7Ak*+90(pt58C{(u0%5`gfnZ6T12LZ9 z5+{Puxu1H>p+BCCVlst`!n_Z)(oB?$eLl(9;Goalk(YfVh9FIn$7u2%5tW_s7UYKX zC2xnnM6MoVSr!o+OUfjuY(Ri5JeGU5ET>tBM;tA=oxM99V@|CivByORrz=1BU49e6TF?3_f^`v|{E^@ej}%bu!N z!vt7nD{rH#;4NT|t%9dT)3U_Aq1!~O$E6=rT$jZb#FHJYBov_sE6P9}>edgu33W@B z;0$_D)>(&;I%=-C>bnlltqcgbB{J*QN|=3HB8OgsuW`}_zNB{Gp|f+k6}E(9DN zBRgiIdlJeit6EuN2WiDuuYUA3RujqU)jn9f`Yd0mnuo=!8N~HSeaxzArG3@n)x8z4 z_qq;E6v(8yS&zRcBnyiV5w=-8`RX`}=jX&(oTJhUi$4P#K3L3nrN|x2|HvYjk1vgL zVf_UYKT*R`8pHx5NP4g2-_nx&Jy#<&sn5 zT)xRPcIEpg;4m9ja4D+kdJzvSOD?O0yjV!qN3Y4(gdzM81GSS4MN*F^*pYfBH)OI$ z_wPe`w5EbJaXwSD+)j0(p}O* zI7>HWmz%xNv9K9yL21q)A*u;h@Cprq%j~7Q&9i=hWxEg)7V$=x?KU2wn}yBRtT`=L z!)6c-m%h5c!TLfjea$Bj6R8jH*?#+yzFW$_? zJ6m}(H%>(+e^snVCR5tT}G9@9C2Au1K&2F<=XX0QcKTgPG*R^|cvII*i+v8*x zsJ9Ee|7|LheyuZk{4!+naY7|oAemIQ5ZBZ;GKo5W3lcIpTxIh6J6ehBJZ2JLCY{oX z!W$9_6|G;Ab?Tph;C!|Gv=dr!c%oX#0@zRf2n26~jm}ZVWj4#r<6&~ zfJz2O=fm4AE%@pede{kCK`YjHoNl~je{3VQy>>X{?no_Ad!M{8IN)2~h(qudl&!gB zi*CH88(&zLUenq(l{_7{OY?voipu(=o2i$>K13Du{oBO&d2bO#WdfzBhfMC1^h#Un z89Sk`(e4S(5c^;|$g&SlqD&&`Pk)FSU@tFjS1xVt|L;Jb#sRfu6~!Hu?=saXbw1SG zc5~TR{k2_;&CBCX(hUiD`#P65hO72bzIIoli22*JcoJx7m=u@8SI5QcSe8mpf5RFC zc(0}KZ_>B&FP+F^AL<);W_`1YdKiE6pL>dawd*VKq=^pow4L)~+N%#fu|rATv!cpz zIvBCtjkn*F}}5G}M_Nv)R~I2w>Ds( z^((6O!#YLAv8;M%qoVtv_O35cb)9#K*x4l7qMskr3Dwpt*f7n`H! z@U!W+mj0oK=(mSsta0)3_-;_om&fL%J;aY)9`kw*zm&(op2BaJN70Yte#i|+^?Z5g zzwg=du%75K{8AoeJ%!&irTskEPqdkP*me10Jg?sJx z+4rTtXyey=3cuYSty|o)@+rf@yuJGS|Au_BI)mR{5YuWs%t{#neKM22G|yi&GY~-_ z;3S}BS0<@&MH8Lv+JJ>1zD8y-UdkGo(G7a-XePlhRbhkPa`_8p4iTNc=5m=_4&lif zircYpP$j_R670hYYzD*y8PhrR1$cVGWKsA#z6?%ifd3u>Xk{J4X?%j1J{*ieVt&O( zgl4^j*@49>G?ydXDNo7V@fBDq@mT@JGnW=;7cM}gokllC<1Cm7Ry5O5LlQE@;8>?K zox7JzeIs_AgE2v^bQpw*LB1V$ip}{?m=~MUr*tu;&+;h1Is}#vLMbqeC$k`1Pw|sg zurWgq7B=aDHCpKi<`8r_^x>&5;$#f1^lU^}Z$}OSGg(d3wbN27akhyTko6q*9mCYN z$cJK%%_kzqSE+cxSy)w2F_D`-W3!XkVs1eocSxK7(+7LIeRr1CAMAt04^@D!KI3`oT{;_s3oPzQaC(%FBUjHZ&ZsD=do zg7?%Ar5S0W0T@$Vj5I)e2+m87j;a1~x{Rr2TrFd&OrXY8pBu$V5D)^chklIXf*NxG z&2pkXFcJU2_X@>OR=|g6FeECrLz_I*i7#pb+Yy_L+Tny&&9esFvKyyO-7(M;yfFij z&4`>~X>)f+cIetEH7JUq+uj5#FJN5+|L)SYG4;7?5WiE`E?lo06INbm4)_!(v4F@PMaaDp^E8s$lUME19tyb}A#WO%bwn@~Z=Ha}B;musgeO=6QC{Z2@&oT69?!GM zIsr5Kg~rF2jVw9rc?Xc{#y5>MD+!IpeUHyGpQNPbyWIYN_0o;Y>Y!D+$C$8r_DwH9 z7m&INZ&GKEDl|t&JVv$d-}W7e%}V_zPA_RO9ss$d)ZwR~&Z+yWQuMsFIIq|2SMScN z_h#$-NBSi`pKhL^8Blkl-xCqr-Dt;iNDx>S+`N|UT+ne z{TyTSQeJgyo;99q@bgXb%^&LKq~y`LAGi_k(tfkro&1V!eCx@6-}`G6N62HI@-9iy zjm>s(=+8%s!=3j}?I9exrUm+75lAiw``CKs#e>xBZ=(=bjbn*@@_C?)L2aNM0sq{Q z)v&F_1QF)j;V_0+jfH}&OQc_JR0^=bJjPac-gnx)%K+=9E28IZ*8D$XW_i0^+OJ-A z8?U(iuP5zrX-?0&=IJi&v^8_9JVr^Dp7*BapM!*MVwyLQtAw)I0J81o_`HA|KE& zfrddXv%f%DjFlloq6 zh}aVJ5#{2{T+Lt4YUgxq%C_m+(#Z?f;)P zN6Ur>Z9nshJz-R6G(^$^|DYY75DMQ829bU<3ZBKtPXV$FvHnj~LFtZl)nPbfY? zcQoVn3aB-voGy>zbQ_x^>AA$|zr(3sV~#4n#p}cHI@l)?^7bWs+=ivQad><^#p*^^FD4La95&5uu^pd9Bc+qb{qZZ<5K$j6m!STW~Adey#d z`Vl9$+ZliNe=<)u<$x*rg`UhD1~~T)&LLLO;Mbcu4NT0l=2y_o#S{Agw?uzdgrqJDJ}MD-HQc#*WTs+{XN5r}B%cT?y1~%^E3H`yS+9*F_t5nnrMs zU&=-C&!9~K?74p=I)2?5dLrosQ6gY1^+9MUw2`I^KLCLo@U6Rbc67r?X32Gr7J zRh79b1!aGM>K_7r>$K5Q{p$cndrymO&lVMbDWYLq7c($Z&p<7YI=0W^(neJL3viPo zDn1jaioazT7Yih|H~Y#zt(4;bWb*$b@((D^KRYs=)QBjz`2~A$`!zx2Nstc9w-?6W@`9BXCcT$LL z_xD@we_6r(FK{&-2;!ak_{NtEjDB$_Km|!EXoBm7$OaW~jiKD}zk=8kfC1mPz zbS_iwglRuh--s_xM8!cLom)N3)KBn)$`if8z^0{HD2uMm6;{AO>A?c0Ye9l<#7{|AhsESq)0D3Pw0-`U?4Qzo6m|{S%)N1 z(xNvQ2u(0mc%>Vaiy4@y_oBW=tMDO{qzeCLyfRftplb1%XTgE8oFpMyGEMy2KDgX2 znMKg(XqKgeDA3MG<1uPwN2LR4TzUef;lZzx#@QOb!9Tgrfux8uPL3tGwMBxMxDI9l zT(%)^qkA`~WB6R%U?7#U?M)lD8d!?)kg8@P^51C*jIRRnPB1RF7!YOK^#IfH%*gsf zP?jr^MQf3Q$&GbC;P9cct07t<3lWbJsRLo~WG5aPtL64B2J6Z&GZU2M!*(gjEe1*{ zS`5V@b0V`&Q5gy$83}|8NFc;PLIN~pu{%C!mO#M^V-qNV+>C!9wL!1q+pzC|Kk-M!`bmxq!pZfiB3eSWzjK8k6f7=yEMcS|R#v{t%BF(_QA=8ZdK zq=}2&Paq6#xHryVje>>27ZfZE{#n7o;A09F1|L+gFnFJWg~2-&Omk+<1{^*~-Bpl$ zVQkmXkD>3IMf!3iI(VvM0c5LP78o<;J2U;V5$sjVzB+q@!D9_{=U`q0DLar>{vDl=_+8-~{XV*1GB8nD3#Lj_YjW`RX1bZskEl zb!g^2aS>mo;4)@ANx>rGu?iLupRZtxY0gxzi1u!vX#9DV@)N9SaE+aU=u zh22iF4!sRBy&A(DSOZO&LYPSmSl1F&@o>zL#Z>WHbukcYUdK+xqCMo)jw)Udh|6iX zf<;cJD_G=ooPtG8M<`h2bcljQPLWc(4^^-* z-%G*5{7%0JLYV&;aCj!h!ce$Q#UJ+E<^a-4=fk7B%W+3qMM0+!J zYXRH=LoSI}NpGMr-4>)BiKpc(FGKD74pt206RYM1>Xz;C{walfDOnlO4Bp?l4v<#* zFfd@~SNt|iA~K)WGjLHO_Q(10%K3i<-)qAm_8g;i0@1m>+=HY$RLBa6{ zc!HLXLkbjh5s}22mIhZssla&R{+GF;D$EinlrUO*qs4e>J>rYYVu6Co7=Mp~%L(7A zV3Ea53bq)3rGlFXPgJnT;z9+BEY1TQUWF9V(j9T;o=QA#hiql4N3uPXc*NMa5?8rJ zi7&aJl@fl$Bjtc-Zix*a2L>U2Wc@vYxkWpI%7wP!PzcR+wCYRvezW)Lp!RR zABH6A13O9zaG6BCZ%0WvJ}gn&?5HMwc&|ilwWFj|y@&!qQmQimhd%`$icl!#?=m?R z0Qu+R8{H_)EK`re>_;CSJ;n`k8%>VHwYZH2lP`bGEaxZKzFNf1tUyKH*@@=mG-R4^ zK45Z7m^)m_CSmSyC4Yo@ISm;joS~bAc&;GX*xE`?a5;_e_~pjrB)Oa>OLn;CV2A54 zx;=6+jqGsEvVJ+NguZM>gww5 zE>@vmC;&KsV)S$ZsKLV5kon%${{4kJtB}~3;^WfQ<$N?$*Yh{2&^397C?kAoF_G8C zd|G?(s7&+;fM>$KxD(jz_{-aaFIwu45`iye?ZQ37?%Kn>oA5>XFH`WPth=KNe<|rR zT)~&JGNKEgx?lfnp3q0i+K&4u(x;N}OBH-6>xSsUmyUdZf-hzDi!S^k;$MBE&_~Mp zH}0cIAAvui6PVqQCxMq3f^V$47ux)_)ds2yu>KOt9C@UzSldwXuQ>Z?v03|$)Ap5Eo^;P;WdhnF~iyl0s|E5K) zzDoZ^51!J0(SxV--_)qpSLwg#!BhG#dhnF~n-aD9D*YEdcuN0851!J0(SxV--!)OA zpVEKPgQxUg^x!G|cXib2tMp&=;3@qVJ$Oq0O^#Z9mHvw!Jf;7l2T$q0f~eJ3>A&c~ zQ~ED@@Ra_W6t(&){TDrWO8-RA&c~Q~ED@@Ra_W5ViU${TDrW zO8-RA&c~Q~K|!sMS~Lzv#hJ`Y(F$l>WOiYV{R1&2T%Cr?fj_KSK7BUdhn!uPmLZtY2VK-k6L}D zeLc~GC+#~tdhn!uf4D4a^_BKm7Cm^<9=XwjC++daxTw`v+T;1?!ISow5IuN8zr^Ul z6Z%zO8eRHbA3bgX0OZhp$5F`qYQ6Al;OSINeo_h>s#6hyscsT^I zY`gMAI-YS|mZGjptm^8CQCDXZ!f&x@U!z=B*K?&xt+Ni7;PU>JNUF}Yqz1skyGC3P z%q*9dtM$1`3TyFJ94hNp(-F(B&&OW0<<-26EygZAP7Qa)Lds0sav`PfDnyVQKqXJf zJ3X*YL-JTE>JCRLtCLR%{LkVAXBZ;h9Geev<@0WNP;2zLF9El@<0Wj3zMti~W#6Nd|;26v{RF?H5`$JXhy3%!)()O1i{rc;XuUsV#8H5?rcD zT+KZyVRzZHprW;SERQyvxUyFa`+@VpD@qKdc}B;L-uuyQqI_-Usq)8Bep`2O6-GFnhUF0vl5OX zuv%vt5CU|(b*2+HPke7lOpTN{W`a+W!R4&t6Aqt}ztP*X_ zB-vFGalZKiiLzh8?>GyLd}VbqHQKg@gy2%!kTtfR;eG$x7t87Y_zv`x%>`19#7$s1 zJ!vS*=|5!xOT~2gYOa=NYX?@#*W&@(B9h~=*NzAPu7*IQ83K}K2uzv?5VlLCT(t;f zM(sqQ+cCjbR?S=P+h1NyZotuRTqb0x*hnh6g&z?hl8jHXy5T9l29bb*Z;070@69sW zC6%~)5|1Cig$3BbPSUY?PGvSv;*nUig_F~kBDdy_1wh9^08U{3dQ~url&|F_2{Dcf zGT#p2(E}u|Gf#Y66Cd6u# zOuoble_{-J$z{oMT)qSQw!jlMStp3!1S3Sz_PCP#H(u&xM`*junDGK@+0J2~6!Ci#_eOI}-J8pH*tf zMWo7KuK`4;q-zA@%ds)vsH|`tJVaAJ21*XiT|{86W#my%ph`eb!v3E4_pVyaXFV04wYpEQFeY}sc2?R zan>?E@Gs+CJghJtY(w9-RJ@P3x$aWyMV_PnU<$jdxz%2b+R(R*y%=vIT-TB^K(M*T zVLQxE9H}Bg+^?-akig!CU{h47QlV0uvu+8-bIFcWIy|SyRFzhZb>37!JUa~#q zsN`(-s1%!fESIJctBYJm4Ibl~IXK}W*Z9HKpD)06qJxt!aE%Eb@#GT1O`?ISkXM163CukKnyn%=)0d7wYQG%Vhrm8Ehra^S$PT-T2%Z7JdQ zSCn|JP$*G+uy+WOREfHDIU7Xm!Fu~3BN_ma90OGeE0NpozC$Pu-Hea={X2W0>`7Lv zn;iun8kK?@63avNqIs62u%~ieWVKkAP;M>#4#hVsY2dn(RmXe6vGlv71m#h)V}+q< zTT!*+QFV824<#0ykV%jRZ)1V76yVX+OA;B(kf)vuHp*8|dFoq%dLoUGvQVB3(eM=U z$>l6jNmzS4m8m3(FrG@4WHBzeuK(HGT&(MCq&;G>_JVq^7t>@h+X8z?fT;TSt3YSh zGMa`!n@RD77OUpUw!&^;4og3*Ar&>Iox7|Cgu>R;SVo8&eF=;-a)l0&934z=q`q65li-OMB(+wO;L6$jj1{hZw=2zR7NYdrC=YVe}q+E0fV_2 z3cd@rtEIx&Zh?JM+eS0GpG<&@PM~Tr}+jvm*b6CK>*|`~w!u7UUxazJEvepEQjP*ZqFSQW)&gc3!F8mxT3*p3^ z+U$IT**7~k2^5SxP!uFv0Q`Q;UYzf`(fCrRzDO}YAB7Dk_h+w+&DfFyse@HZu;3|_f+x}hPvldB zG&r^#IyK=UPyZ&Z>X`XW>w6zFKgKq6GxRd~_r@bLwrqB8Adcv2?>X(D7=6o^Fw5Q8 z-OTRttN<&GtaRQ_=q}H4JdxC=Qrit;>_b3JiS{`)O68OfUl0A8KxF)a+c0!nr5% zB?+4Q0?NozIIE-F-ox z=Dso?Ukfz%MTMwlgQU0!-I2#Cw|ABSOFUt~OHdb;Xl~Ipc$J_MELI6B1%o@O6!EQ6 zeOs=cR*;77J6EWuBBorpL1?0S6T9ckEZh3;dfH-foT3OfY{3|Sz=cCgG?*HoO1CN; zIa)M;diIP?_ho>Mm$Dd7c0EYd?|8fGnuL+A>#SLoxAfP_Hzs5IX>4FxlWrf{WT{A$ z+}ZUY#B#yy;s(1ryOA`WtdDfhXhIbT&F-Gu456kfMaNf!H~MiqU@W;s0>E_Cs+zwJ zwqe}7$(koI?OF^EuIaf@i>>oFW|y6xmu0yRo8hXFNbzXjglx;&-b3=nQN^tp+oOEf zrPB^tUp&xPl)do$5!rS-_`ytkv@d-c-{v?Hc+Jakj1GR~R=o+uq4xKVxp=~6vcM6% z^CKMvN#9|GzgXxnyNSF%X%$j=x8q~h+Souzt9+Xc%?0sgTQNBrNk4oW>-xOtSGTkT zD7Y7u?X9i5aq1k$jB6&(gc@XDV!5N7*0(3|tQnycz!}Of&zwDLdPZHgJ2!6K^rB)% z#`c5IV~<1X?6rNodCXNc*D+`ElqoZ(+J@rH)$#7MCHwoo4oufjlnl<^lsZ)$+i}MX|gm0=1_-cai zBxKisHMWON(8_ns)PQuw!vLg`Naof_9CJfHqp%PN4nlBFL^0WG6TMg9s*=u_GkexG zvu90_q-#;w!d!fW^J1t|o zp#DA>HCW&zln8Jt=`21?nLWoy>Rbm7%3j;kyBgP!ELRg4v-Hx|6+0YL=S-Vi(qFMSR*}AM{mVcku<<&<&P54ugLJqi);!ALF#DXAS$?Zh5iE zKJ;r;TXcTiM6vNK6(3<~I;`D{8oO&3I%DcOo2NfE!QbIH2|Cr9>lzskBdtd^3|b5y z%WIM~|CZuUeTmuC><^4A6hGv|qjUh2=(B>zHbTCMEP6`pRXo7_0Li3)`jdQE6iz^R zfa8VT90qF5P=f3$FffUu61+4H9fpUU1;!_FR=ye^hb}|3mTp`X~k1N9F7I7tZ*Q&TOR)pKt^RG;EEsCQ- zy1625IbV#dd^PS7Tv38_RQN1?0h})sS3I&a0Cp8|Vh_W>H{RE&T`!%#R|SKT#D6cAfeYy0=IB)VCbh; zt?wD*8>6vB`3J6W#*pBi_+juB;VFSnwN5K8;{9+{C-Y4UKrp|ViSP<-J=qkre;UCL z7$cywP=f^ELqzZ;la~;m6&(&Ee!%F^1xstMzN(HEa;?}N{u4k3t419Vh2|4GZmqVA-}tAeKS&Exx^0+0$^uy? z2)U}o>M$KhPH|v*oQ104QiwB-oxl_SaJv+@3F>ya`PNmvk|Kq%vh#lC3wmI3GHSpx zwxP|iy;wWTZ66{5wvQp0(Vm_Qplf>DW9uk4EDD|p$P?&B6fqS*+%N|~vki?S12v40 z@I)hyMY)#c@#0>>B(SPfo15MW;R)1`)WGh3k^yZ|n?p^2!yrTYrSBmeL~?Arq8z*`@{Z1=F_P8dS_ zpUDtbz}5eLG8n@3Uq41X>ybM~e`f)`hRUuttbR90SeXE2qS9c*@x0>enb^?>ho7c% z51=%Socx+MeceJp*j*$6-9dk`xjsi%q(4=^$8TG> zIROA{{;e|nBLSr!{N!4LKjjRD=V!3@FsB8G2+jei#Kqz~j(lNws?j0v6Wra{lL>eF zBhdbOSt0z#ankjS_VfimeAydDB57}r_2M`P0cZGaTq50I5|SMkHn!d6qm!mWw6teg zKG9wO-`-ba8nsfYl9dT=P=mdXf0OVKFFu7_w|OxpqR#R!~NC%=fr2ZmsAc~N|wM+Alz^KJWuq=gjn-0xDz@bE(3{}_e5$uWgd$i{D36Jd)$ zO>SzV5E-Fqp7@Qx-mJ+eRY=3$u$s(fAyZL^jQnM$NPliS-d)`C-BP|APGmaKC(|%X zpgI^dVvK(NKe2cY$=J$X()L>f^(>sE<$Z<8}OK zc+Wg)hSA0DGX5!B96sdK@xUfr8}O<>;3BQaXsz%Q$ieQ0LdZXOJA_HG?YN5E1bTpT72CI=kbCjkbjzm+(0xeNgSHChu<$~E2FZN0%`_s{p7_bfV!-P*6xsmUvtJ`gI6CpfCjurl`cS0724XObC^uF3 z&c8C$-w?cE;?+|=URY8{B|W`I&AGvL!W;ecENoo2<4bHH)xmioE%wJiY{C?t`Z8!d zPkL!2Ebp`N78@rP2AK|;Q ze_8kW+3hI=;`*wSgMnlFho&&}fzu!EYOxTV&>pL+fVpG9ZZW1~i~DO(gj?U>xJ^u3 z&A+X9XLLw^0Ob?r9cqEfh+PU>i%-Uo*YMKP<1=znfT=JmbNEE*Yf2>T&-&VjP-$as z@UKtp@kgi92ZrB_eLC;kO#QDWlrjhPWXC9ccna>Lf_?|*>+YBS4~@bcZ&YhJ<;On1Aw1|1nG`@ zqlMyu)}M-F;Q*s`ZfY1froOt;&@h2Hpp{qPjbX3#fAs%M{mRJS%1bd9QAsM!@BO=@ z%jjyv6&<1J#(q^1_wVl?A0@qdfbG??YB|Q!(bsIazb=-(jpJ5*0q;$f8}4#C%?X!N zVzkWi_Ro*o&+$*W1PX~Oxajhr#8rZ6{YmFt7CmFJ0_JG+`w#dI@_;J*a>di3Jt%8Z zm&bv5OSkr%aZ#!ZT3k#%PYK+M1I|T3cK49}fbO$QTdbAWrrZ46EETiqLn~i^!(?}A z?z8>xnHG4q|3lx718KjhxHH3lX&7O>FnbUcD<?O;S_`{7L7wNR+4&H3bqiOQOSDB+|za(yc`h5}W(R5`WSj zg>+tMQSKatw|B)%V*@foC*s)q!g2*#sQCgVYc zUH~+pLjQ{LrEx1*XRwm+qvHt0i%qTizRwXKVpaf5sI>ftxlz(f(=9T8xmuTM-G+(;t99w<-Z5og$5t+T z1F2PL@c)(iZ34z+)xnG5X8d$?su%6{ zDytUU92c7oxM!21G*JBir~M0^T7~w4mUr}Ipn6oW?D~H4-5(;lO~55{6axzvbqfnWR-RzF~= z{CQ!Nbp3&|*F8gGpW{;4pP!YbuuTXBY`qNj1AeqrEMyPo?xm?Srwc(REQ5^-$7*z| z)v(i*H#EXDvDk#hBj*xs8ZRMSzq9nIK9k9V$&#ijwQv*b+G4s|WpgekaAZWt!eZP472QPqg?qWkT@c3#Q;-x$C|o=- zBnpBYnR$?>5trTafHk0u(?%_6Nl!z(F24~es}babj2t8`mCF;$ammT}!R{Gy#nV{s z?x`lk+@arED*g%nTRhr*)K5158)d@><10cBny{wG;9u7ca3@k0f8s4dQUn(z12mSz zS>;llh6`iUu7HJSpi?f;fua?}1rAJtcs0r3mh8SqjHKX40pK3jgoY`3H_)2dFJt{o zjDF2;X2Y1R2>!{zvK6yw3eYqbr#dbMz7$DoER~GWLORqtQ6%41>(Ijwt^vXpvPj-TKZhS4(VZ}+L zySp^c?k>%@yQdcDJ7)JHBQ#C3oEHD;C?s+AWhL+I#e{~4oQZk3GT}5p_yGjQ3M`Qg zL~bfp%Pr!f!Ik)g#e!A1AS+o6vbM7xLluvQ-?FO`@vATCK{Z{?h9w}T1E5A;`i;Rl z#^7E7iuXQyvL)~3B0MEP%C-2&)@hTJ$0KEclvA_pu2QUG+N*!_dTSzHMXI|?H4r5W zz7cY(?|GnigB3Cd69MO0-VF>*lEhxbY0BzmyfT%?eDWH}L0&6D1zBZql(Qv0yxYR* z;fw`6Op&y2h47J<;{ zSv$gbS=$(o48<7Kj?v%4mLpxk_~a0bC-niwPa^j#zvHJh##l29jQ>IO4YpgRaqW5& z-asiZN{$5PqrrBda~mqoF(H6i+v;tzb0FvzOOm1mg_`BJazf~AdChq5gFz}p&7mm0 z5TCpkBkZl}Bs?M*Q#F(V0ygH^nh3&$il3jF2Htg~rT}t{=2^n*h|hzliL%n)SrQAP zsUEht9~-P*G_M46`Fe1<2;wYVhj_o{qENFVa@GKhDQ<&A#DcOl@9hv?k}heeA2Df1 zNtB-I-kM((UJq$@OU22crJ>EdXYi2rauG)HyXfapIN`d&x@LdUy5THf$W@O~5!&%f zQL|L^2Bl0*ue4&viqJ7TAg{d*AddbffM8eQ(;%#DkYGPSIi$#DsA@ET7=eD`Yo>Hp zOUDxECz##$IBG=lLtXs?>oNREhqkEIH^bnzDOHCnCP)E7WwICPM7~;yYmm-kwuaD| zeouY-AB_FO(g~AU7r)Yq&d!g;Gh`}-exm8nb=3>(_`BW<+DC`JEB4W2+r&O%0%aea z^CF5l6DSh5M!W{cIx0gzLqa{vAUa_^R0t#eyKa^I_Rr?$xb z8go|}xs_fEufkhE+l+f1t6(&~6IO+J0ONDCg})~xcR!W;QORxMKa5$2ww3jgkgOlp zOVy{h$Qp~RmOh>OH_CTnQuvuNspKF>Y8G0P#Mj?FttUvG#y91g@>>{Y9ggo>!^&$kJ0NEG3@og!i9=42Hg-gbR8&w%j{vjbk;1j_hQ17f=mGLP;^^842_ zOrN53Zf=%i4b+@wesT}1D;wSq5Wp!7#YneHp8~5N3NAz^6+aFB7E{9LF0mvVrGd!{ z(Yhv!?UW`?EZr(B#srGb`#*k)B4>!t&e9`vsuNe70LlsZfklFHq=9s+Z`1mHqqWddkI*s5g)VP7F( z`(V?IFv3389$_sIc9XF8zAYk@`c@cWUwH`5cT0#i+^Wwp-^zw>MHqeq?%m#-DL;`OC%AUkviKiF&RaP$pD(P=-8{8bEK(ZbJQgR6g!PJ5X3eRp=0 z>TBS);kW4GH#o}p9S8i-HYxhYv)Ze8C-$Ey?|`wST^K=EwA_h5`m)RarY+o=2{8!u zMK1@}ag{VKL0>exe&vgp=&+p%1*-a%JR~p*y3Yhwn@-| z@;v91&^5qSmlQv5hIkyu5n_?PrWAf(V@{rBP;SBQoV-}< z3hB7L9G0^IChFLD zDMo)8TeLDuzQvkb5bn+_8-9?W?#$uz1>5pUY`%z^3XLqzA$T36Q$My3oYtQBDH}uj z|Mg<3--KOB?Vg-GH2^y7VGPwDK$Vrl(vz#hy(5b8qC$lYUuNAQFEb?$PMjq$B0(sVV0TPZ_Mav=lGtq7D7+ z##tCTHIpC2?}cfW@u?{`IBQ1U2Wb`NL;Il)fYmQ2840cWK{o;)>`Pi+s>>+hrc;7e z2NCusjl#Nkw?&pq`my&-9t0p`ils9wKR~fhKW$1or2rx>Q-tn z8uzTh=B)I2m=_{6m{Q1e&-y%lO)zQ;1uULO1A6CyU4k94=sV%`>h@|2}^Y zBIioe?e5}C@OIX^WQEOF$p$u`2>e>RE7R^Of;TT8?qfP@5*;ZaI~<|D`3oK?J!x&Ec2{JVeMzx-<=&2Ki@6NAEedTtt+69#b0DuvifZPur}6qY1B z72|^NauWPWCjyw5CoQ3MPG2e<8{FA#WDbvCYZD*ZMlb)B5h39x`Zu&_#}s zE)9x~8ScH2U1UDG$k*|j+>MUVnp-F=j-TAD-8t#1hs7ca1Ay$921OTv9Lw4tIbOoT;b>4kh}| zx2VjqL|%}Z(1;IEFXJ#DgAY(kP_g|#Lk?(whBo%TeN*O80Osla&9~!*Uy!@~^{xsw}*Tx1*DvGt%xE0rjv! zEC1^Ej4dE){0mU+E$w+wxn~`IM%G!e9R{|(dK24?c>31chSuZo`SLeovCW8WP`$=E zYWy{xFWE!K1O1RM!RD_k{xz6LbK$K&7PACe`SzHfvBOWC-II;}^>2Ryu+4HoeRh># zTfPs-Ux-_qtKL@L_~*}-L$PZOaJ`)W-}NQh%GdYE#kT7eha(ZH&^T>`#X9n>glU$| zjj?!cm}VLO=`_ovkNN(TZTy|G4PVE9iaIhhXj}Z zFwL@IXY90*mN!1=nZ0{MtmD3EmZVSc{~M(Hl~pQ11=+pbdS` zQjw3k*tt#ck7!97w)^=R)at(xNp}2f8Q<_TsK_@xYtdliM}E|V7wdnvu+2G zsx*))N7eYAQ8J&CLNP9&1Y1$(Oz4MktE7{%W>?9i|9Rhlkx#*zt{wzE{ zxKDAWD+CNU^U5~Bbftb})lvt?(PhKe+zyTEI04PG8h1f$%J8Ug45gvJgRyKjbn!N} zaYz&IT^nrT(lye=hpY;1;uZJGTtk_V&}G^qt?CPdQOE9nt!UI@gc&lvtYeE9u}-j4 zvw~gaa(q!;CG~w{+g;`0|Dbg|xyky+UUrqqvBgKBis|esQNfhZv7|*hm~jM_tnav% z9c3~)%06QK=OB4*8e|NCRJk#`bDClu1k{W^64v#F*3d!ZW@rX}Ll2~*-*8+9zK2)> z>Z*a5xEHA3APo>`4Ru62P|JpYSAq5f%uGwg2Q0x8{~6u{{i@zEc#EBseF-qaQMf8o zb8XP8Yo!-V!3Z{uyY8OGoysac0{u9*zE2;XXc*}a5Uy*ZtGT=}4qZpjxdUMjPiAV@ zBBVwSnJN9FeIt2(pRT*0C}_~)!R+6-9y@pFncFPOdiKd3>e!ZGSvImyEbpwmLqFo! zmdrbppt`#4j5EEM9^=?IZJxf{uuWH3|2Agmnd>hAz#cY#jeTfM@lU?v9DfzXK`d_V zYoN~v9H8%ue+4K}u_w#S{b|P-5-KzIF-P+WCV^{03a)9=Feh(q_PZ1O>!Il}HVN+P zB$}Qq7%;a%S8xxvwqxv>_%dJvC4I3}R4z8PlSrPoJ_lhwFom2#hv~RX@8S4H>dWAPb#_-mpvQ(;61J~^8`zq1H+*D zScxa4KG=>{{6|P23D?T%P9QURlFmk(!Ohsf26KN4ZBotKSZs%`LLCkB+u4Y>G@An! zI9wT&TYy%X54n!IoOSpGS2tTK+(HzfqG5T^65@gGuWa}OOn|d7G@dxM8YNNr$+b6BAxpS>K;9D4(Fo3r> zW*8X70wc%Sda6RDnNGZ3L}k#;15H$Jj|o8qp@xBy<5BWWR!B%l$<=L?JT6&=LF@`l zm={cEC>pDWuJQ*4b#{rDhoMBFFAgQDLFRCWpoUF#@me484IO5lX5OETd)bVR`--c^ zQIrEoFh1MjI99*xLg@({$J<;ua%8=3$FplAPAPDVM8u@8aI^E@fE4Rh{ikZ6B21K- z4OGz+N&%s@jYx%Np)ewjstFhGvG`(EdN{IRC;jJH?KV@}+`XJNC01-@Zgcf2#;|0U zUYpY^CZ_@mGMUNp+Qy*`mWph`tj#IF8(s_X$ZHXwt5Pt|gX9ng&687rTjqp>oe#?z zPmS`Fw0<$36t87h$(C~q9_bNNQDc!^!!Y(OfHeSb-h?94B{w$S=q~RA#q3SPwL}^hi2p#6UovEJX&T zn?7XkEd0luHIlQXCf@Ai>;#X3Sb?twYWh4(E^Kx(LXbmpy%3K`QBRoTaqq3bUDd(( z@%0B_alIH4FVVCLC`dFiHLG%bY9_azM&Le_L}+{^{2@yS65C&5lUWw8zm7pZ%#emM zj(jA%vQfGYu_b&5n`TcS%u=~WIlRRQV(db(jhxzYaN#EQv}({bL3vC;i6#_+hFgpa z3DPLnLC!KTRgj5%kB5K} zVY~)l0$qu*+Hv!L?WiF@w?_#3)Y`YoZQqxX!_zkRpMrQaM<`Ye_ni_+C(ft8?M znGbt@B=lxFW)U*ghh%V}bMwashCXH061?Cz%WUikD9Er3AbAjw4&_g!jOjwk4%TOu z?`&TmlvUG6;j(Rm(A5oHXvlW_uU*kUCjXi10Q>{Q@6D8_ zvkox+AAWdX@xKx9O_sX0D`iE80v`&D!#A8;%dAVx#OxXPcj{ovkEzvhlM zj~#@mz0O6roBdmPX}Zi=Of}0=Ub3^gR#+tRqV(VU2TWdspQjx-_#%(J0KZdtRPk(= zA?PAs^UO@m!}wb{3E{Y(%azo%IYbs#Xl?`IikkJJuNh>U(^y{G5LCx`3`74(*C!EMwD>HGzMmV9;^#_N*^2g`DhWeRRmg2X4@9OE#V~4(z@q<-5E%(QS#Ied$+HuLewz z#TI9vUX88m)zrFPdEynGuo0GYR)N0pOsSluf&**#7TobOt`9#x#IrOXA1!Ojig3Yl zW5-!I3b^>_wkBHL@W~aKdkf}^JGsvd}NE*wbkg!S?k|0efR$tSXuvfp}OQC9Wd1;fPYWQps+A=GgstY%< zs7d4)kEUs!yga)nt3cfPC^=erE44a|D}0gh7hwF2#~R>W2cIYIOY3p3HrVpSm#fc>c*T8a05o?6;<{?|!Q%lM5Bd@C z>bK-rqHU3gJ?|*YY3uW3O-zVB1<}fPrUa%E!s-e7vW80jI^fqmf^BPl)xX$Zel05l zXx%zEoMqQsyZeFD^WALL^?1j&7cb$Jb zpl^Hnpz`zmrEgZg{!)54aj%%M|HM`LV9#laxU;`(P25}HvLJEie%n5AYq2my-+-S8 z^nppwBI?&7NKH_`zS)Xjt3%+cKW3F_43efo_*;hZG&EP3#LbpO2$Pd-20q zQtC7`Df_o>*b8mfr)~RZN5u7k{gbIbcuM=V5&MU=Iaz8Gx&70P`Yiu=f9g}B?>o8U z`V>5d`do#d=<{2f?Ni^dKlQ=Ni%#mZ_)*lyji3FgkEuVqVV^dCB=y=K`!r8K{v>Iv z5c_n?)zRvYrl$cx^f_PbXx$(A9#;L)*PplFADxXjJAE*I_M<=Q20d4NxZm_F)Za@B zr{_(RqeaiZ3{>>|`^T;6`OkkTdfqE;^|tN(|00}C{|Z0<@6a>@UtKE?1sIR@V?M0)rw zI2{ALMvVU&FtEF!Y%jDt_`@>$zX0KjtW_f z7-p6+VWVl!g^bqpe_e-Y&cNTeuH*B_oe|*kDbAhf7@tF0;3LEFWQ@8ofwd}?+hfR{ zcNM95@{Z9loaE9xcKo#5=nm7kAZyYN7skV$MXBQGs>U&E<*&+pZ1IVjyCSuQsU!-) zT9LXE524`*5@@Np30p-2IrpKND_lsXKYl!e8`7E>e{=h9y<&=Qx%nodQK%AgisU?;{i&C4A5p$j+Q}ZnS?5S`=ElOR?@;ymU&g6urJvGmmGk2_! z3C9ZgZm}*(U5a$!u`}Km1)glsb!n=mO1~D%tu^=j)FNy+U5HSf%U|MHJ2py7F5JYHoiuSHsZ>I`?;+|sJW zwuyv`cG`Idxd{xRA0%}>pWEl$zgg1Ky%I5Jgh!9jxv7#x&oaL|Zma?oRdreA$* zkb_3<#cSu?cx}1UEk!L#UCu7E+NilAY2`Zui$s1*OkQexpMVbLI#4|Es9-f581#%-qJk`+_b4a@^+{AX8xKTAF zVd`ZQLYq?gla%+f#2lR)VV@tdS>eM~)95HXO_iBcaXZ^Dw_bl~Bbtv6P;7Wf{V7r6 z4H&2=c;is+Is^u?(tWXQGsnplhv6k6j%#CP&7OsGsqYYman@jw!*$2>im_XucU^lk z-p%&clSb!jwOLjvvNkKlxTYId94bjr`5;PGp4``F<;yonNk)yA+65uYO@ijDm?;x9 zu`fz#15j<$++5l^p~-iK8M;;Yh2Y4S*d*PPSmj|CkD$~niQB@P5Ao)8Q~WgJl=p#^X?Ek(CLZxOlu=a9G6Q6EPP%DO9SVu{(HX{QjYz&EG_~k10c&c?+3;ip+ z*lLVNWo=_7ysbt4qz6psn9IOE_SXVD7bP&_cf!W~mB*e=Qy#y1xl8hR3fnP69$)%3 zAdl}32+HHC|Ft8JCu3@EKjl%!*{)&o$P-;ewmX!^xk?@rKy9$*4WB7_yg_@}F=z6O znKNchmsW>JGDQoKlowqS$p?$ND3XM!|8PZ1krY=1I<}VcE9NMy^&HD{TgCQMKA@92 zm}BXQ-Ko(#m=i4JB`G4hTAF^wQQ%k19SqRFKu{P=R(JesKlh4}Xt#s#@NYb{jP*C> zV4nBAs6U4+*x@J4-`dKbqDl{+e1QhazFZA(7w%jvG=C=-(tobz`pAB6d?pHZ%wcbV zP@fI@_T6!aA$6o?!Zt`pAFvY{*zslAT{kAsL+Bj^-ZX{!-cJn0Gnx$RNxbehFuD@z z!&y>b^)m$76F*hHUYP7=o#b7&sG`;OH7>8=x@xp2YS=B0JNExS$iQ>NK8fzHc+5> z!M`$MmChCQnM5s1P=k3Ob`BIi&VJWDbu@NpJfL4JLnhLZmVhWPrMnhxV$+CoHG5L1 z2Ftjd)#C1zZVj322v@SALlpwS64Pto{|Xq!#!W}_ZK{iCJ%3TRMR;8KGfwMZzAc2u z&B?a%%-CWZ#6BxQzt~*vzY^7v8Mi$bcVom@obSum+`}LH38KN3jtFoc{trC(ve4d3 z{sI)yxQTDJ9x)#AcQs<{_YP^@Y6Kp>WKwyDEzWd0mQQCxYl|_Q9DyH8pT!-_$C)w~ zoQ$JxA^9p z!8~AQy6me!4pUP2AYi?rU-XU)5yzewN{b!9rB@KgPjBr=96yw=0Kxe}F4XGF zHMvDr ze;h8tIZz2Fj+CQt?l=#^nFAr@z8>?EgOZrrElG@f!;nO=%F9rAz$y>QA}xPuz=fOG zpdyo@68Z4oz$r)E)v;I(f_pF^mH~MzGA+R1{7emsP=5L*+<{2B#{7(&wVI>+4V1_B z=4V3WF=TQk%XBuBe8RQkK-qU4CTB8DXT!%IsD9bj=H$#|3>#ZH8=m|b#0wcC8MEPK zzT5OZo)9m?f=ehwZNy)-H9ym)JQ?&_xO2Hs<|1M8&txAU_3AS7EkURP-j#YuIN@qVU5H*1e=UfHhwLKu`%Ah1QXKJaR>sFAJdDf zn=2J|#l*a>KfRt!kW&RltuebdgJ|WY2~d4CY`dhvCLS=etytdSW{xM~CkGy21XoX-n7p+xzNU_O~1Te|Y=C2>ipZ-qM+W_{U2_N1Wa8S1-IP z0(?&C6rYKa;&b=?5#aOC&7INb9T+45^Jr@~=#%|;1o-sr6rb}V#i!rO2=JMIQ)l#f z0X9@b^jY|71o-SM=?tHvBgJR?8xi0$zEgbex;R4m^m{V`e74T(j6Q$DXo;x3`n?+g zKEpc2XI7;6+}#iXKF{6Q8GSw<8zFt}{wxA~j_MSjF_Gf4-Wvfvi;6p=&ue2Mq|cW> zMS#!mj?VBoEmC}r{WSu7W_F6tzj7m_&%fjLb$>qkQ*4CY$^37m_*{HY1o+rH#phPo zND=9u?T1Bx&#TylyJPzNI66XnF2T`i9njjcy$E$Y{f?09nG^W62F(dY9EBc#v8 zWf9ppcSV5DqM4o1=e3+r`ba!g8XRXbnZm6tV!GUHWG{kV=zys~$DcO{;R`>D$uJ10(zc&a0@WIHsT>N%t_-nC{AyN@L=wo%pK0Q^;Ll5dF}lU5e+( zB;b&Ej<4S(M@f8DkKcnF_04;D9Ts0jx2}HGYKiCIy%}Fcw>)E_YIqGs?WtHZ##b%J z2Z{jO1Cyu8<5fJzQal>*RjVO4VsR*xGjE>W>VYSwg|WCml@u=vObdAmSYB0t%m9> z-fGd+Y)?!qjQCf(@m77%gl4?eLx?sLpZj0gu5HF!{kAv0RlL=xj^eGP{${L|D?cA; z3yip{LiyH4+||gJI*+?*dRcG{#9d_-hR0n^#G4M{t`=;Czy+p`E`EzooyJ|=auzr_ zH16tJyui_K6|4#X$aZu2?# z>2!Xj5P@#pjc15O9a_XQSmlK|zjDf+pjJC%Tj>1C;+0*WUpaHFL3<;fq3=Xafk@0? z8{tL0owIYSn?K|x6T+%LZhD+L(#7~W%0kP{3t%sF6hGGtO%)P9clWmL#LxY0mBHXf{M^Yn8l-&&KS92N z!8?kdOOFY%^h1NxeLF1u?FqutKQi*mGhiO}K@70;omd#we*D~(zX(e+ehw_1!dxZ# z**I~cb^P3}Yr7Xe*A|!OK4NfrB=K{*PVbnp3s!|Nw&6nSsK041elGvX{pfFk@pJut z4)1Q(p49#B=9;>Y?&dN)NOv=?Np&}GKiayx`FOhOZni(!t?nimKbJ5AS|koXoyX6$ z>TmYf9zW#@@VVIIw=L?DJ#GuL$NyH#-t0+edVNk)u;;oIl(RVR!)wc(cS$#()-&jD zJso0?&l4NEyY_e;cEe?SwX(-)C+jDacflU_dXQ9^qz}R zDPOx~Up$7DSf+hpF#?<0QO5ZxqKqeR5@r1T_7G+K2bWj08^7^3wmEKPUp$j!*cV-o zmuyS(zW2{4*cY=;f@bhe=~xYa{6dHt?nwW(7f;o&d_VMWNIcczo#6`jEOe7tOw;#- zd~l=Y?zvv;V9OvQfcdKpAqw~dJct5*|9ho??aNv#;QXtV0?w1pE`oTfJFuWszYRYJ z#NO|RI<%0-6nSCF<1k-P9*^D_Dv!%yqRLu=F2pa7e72=L9(74a@_4v>?UpZL;~Q%z6fNQ#Rs6EHOysd^@ymwI9}>TuXT~pAy@2@T(Lh<^mo+nf z`3MY|;ev6r9l!ht#$2u9m%n51cZeS{Bz}2xK}h`a>nBi#x)HzJ%APUwSC`|L7s&#V zuEj5>tT1H6h+l3V(~*q)EMFsvU;ghelog3zzEp)WH?)jj76$bsJ$!@+$_lJB#V8_! z{vjiNIp z+bKThMT*b%&mzEQer{*xAbP~N$Hz`~s=7>DCz%0U&W#@ywrvOz`2CK23F00;5Z z;Fa1lL~dl_!lU*L;b1g{Q=Dlsx8zB>n@f}E$ks=di|Yh4AN?7!ufbGG{>3sIE@5v? z4-R~k?}I1Xlo}J`N3G$YU@dxF{ah^XHz&pipo6hEpTKL&oe#4+7WntWb~P3ibJPGA z20(Mukt|MME|yoABroD5xi}hNm?sTBL(TpcWmj2{!aE%_K+YlSPy{ie*990AUyhf< zml-c>)Ju!A9yil?IuR#~>*Q^X#W@2oUW)bpsX*Oe1={9T9Vovb1gzs}qoQYELk~xR ze%a?zNjc3Z6Ll}XEojn4AzY8cTfRH zYSJIF{f*k5#W0P=_n4||dJDY)#Sn-M@eTnGn;d%A0|s4(Q@lNi)3Q1mkrv2Tcx`?L zx@y6>te&+nGdQsTuK^{#@xL7OsHxdShZ17zsaeV<_^-5&j6iq4C?nAR)yPQE;?(Sh zjR9B9OHABysrfxvFApLekD(@Z&^M-LXUAX^n%(7TS-~M}TK1vayXU4^4%Da-LWJd< zBrwFPv8+n_ld?S#u0ksz5_w8??Vmhl>C-+6m#4ngXvxzvA1HY`uClc}otCTQ>BL3t z%hSzcK)z!9L?lnxU!4Y*F6^aNpF^;Uu|C?^WgSb;;qr1jS0aUBaH*xqRH$iWRWY*C zVKp^|8iCm5rPvhaXvi~y%VaaIoU|5uah*Y9i|fgU^QJb342InC+pz2y z{utiQ$H9%bMIb~@mV9;TEK_ZcVhBZHf(XTz41@6u@Y8Pr3@8&X)X*)Hf@S56lsPxf zrZbk6C+cO`7J!S1Wz!Uqio@y6F$>Z!`2%M~eL#e2M-_;d#?3SDxe2c=cfQ7)uF@sD+ZTIzL5o$N&8qBiteEOkgwwQi7~7I>4d} zH<4|jO(tyA9i!RmIcQ&5Ll0xk3cLn^hXRb_)n9)=oKda)oodrE7zEFh0V47_U^f`Y z1Az6i3h-7Av`9yPq)Ca!kflrg(HBpL_D8?H8|0YJP}AUo>`4%FV*;UT{n4NI7<$R* zk3JmQQGZk~Upw!Q^1cKujsEEOJ{|T)KlBvQo5rXY&wvUH6axLxU@V9>rt;e8k9vRz zt@@)c_Qo0gQC?TVgcO6f+<>TW8Tqv9%dnTDVZ-{PXAXfvO|`Kq?e|BH`;Go6%KG!U za}53Y*v!cF=ik3z1B(6}CI{DatUuG@q!Ay1s}_y;I)+rSrudv*Wr`02 zOIn&Av2QE!;X%5}D5v?+Xm(fnd%r?_-sQ)j`O$2cAAf(0;!}^0`U_iw;Y1 zxA}PqyjGp6=f=DKf6veVgD;rcQQve;de7UvA+ha zuSPj@@Mv~gVSN@p^31{Fb9}?fO0*b<1p+?%pNb&G79St;jYqgu6Iw>)$Z3Vj&l%WB zSATjKoX277hp@yeE%f|B(>K~Ge*dz|$I+j&R8@z5(Yc0G^s5KSl$@#x?XtO}t0(@f zA@nnGv|~ER?RWxx7p0algZRk*Jtq!J=PKk4*LYsWsffk=jI`6};h|yR&4#Ko{iPEt zjPWU7!e4sU$rM+Ps1w>))sik>F+TlMfO5;;6M%G<<-RvGo}b8dX`z186+f)50~DH$Vrl5%#74tNdAW1#~+#W*7{PQCL5C;=66%I*8Z{H9lKq#7{& zra16WQA9t}$DwLgPnn6#{m9sh~bA@ig$hN#{ zLQ9`%uNaI`%g$jn+8?9VSB3<(8UBh${HG1rlLKrX@q9$Oej;|&YdIgW6-v3wk)|Ea zM_gN@{HI^dY3)D#@oeQk{q9DRy6yRhbB2PHL+}%k|FmoVpZh6#=6u9&HNh_7v!yNk zKQNBF=l>}S^Z%e{XkWhS=9Hd?A;uuzTcD&nc8HW@UNlirC`oU}jaBJ)tozp{W@tbRu%P za|!X{G=P+4Nt2Qhc_4`3Y9N+oAettKW5R&-fx9<*eD;LwiP@7TY_z*eu;2e)ZT*)$ z?VdBYXfJ=Ac^Ci1*oJPq@$if-aO`Y>wP1JstQTW>wD)m@u-M&&nYqs0jwA3H?{B&V z;S>M?Bk&NJ*7Ut8Q6(8$u$k`)2;#~Nf5yAmj^9%80E)K_{nJvhSnyv$FOAK!Y%wmm z*d-X|nx*mzc=PP8YGuKBmXlB}=L#lt^GxBKBAa_$g5xZk>kGT<7n^Ih=K3&WORj4Z zcZdG*W}`oN5B!(wYSdgm+g-bDuFvhR-)yd098D^#-+`^&<)po#UtJfEqNk?iI=``0 z{sgSBFM4q%eoD1$wwxFX&zD-*27rC?p8Yg`Gb@s)m^iNYkH zU~|0zuU84mtp;dR%L}CRA2B9Ed$LZN+e0)oSKmy#YknroV|ylcv7VBNlayy>rtl6^ zI=MxBpN@4|nVFV;^D{L>q>RkWXJCpwvkb9=aq&9x{~ATO*Xbv3up zTJvwPxjsXo%kj?cRzya_IH!Y8$9huzw)Nliw8d@$nK5OVlcl*NCE5|2Mo&cml&6-! zT)BwBE-hrEyEf{*x$S_<$+i?5XTCIGbB5pUDMj-yTOnC?Ac<;IY^7a_xCURW<+ca# z3A0fl;~V?XuPha};kDiK3=y}xuFtZ&2iq|`*waunLo45qkXk>aV*aDLWD;9pl0pm zzZ=4-?fEiy=>>?z3CG_HES4tCypAgy#E`t^sr-GF>>=pOk@u zz{W!Gkcg;&qOv&u_8WG99)};$VRUD7h<81}5;&jk8Hc0Ba|-MrCl);(dKM~lx_-}T z#=;FsSud;+_x;sqJUR1+Z=0}K*%z-DA|=MnA?Ja&RO*U-WU3Q7e0*vOeh~zPUqmS4 z7Xv8Ep!qm`1Jj^IZ75z#8s1ydkOG<(iSa8L(*#Hw07BEUkQSOYdiW@c^MF0D+jUrI z78ycOqEC4>s~Uq-kKL=waN|$PtTb4~=EC_(2j79#h=^jAW3s>y+&^T$+D}x1{$3$Q z?-8NVLO<<4KAzl6{lkwUZ8SxSOO=Bj3R#_N6kMZqFw}?g<212Vz#?i7`fnzQfuY8* zL(rKuwlXkO)LC!MUujqunCq8qQpN{Nh5U;=!~P&#s{Zxf;g%)qwRO!54)OaJXhT1; z+`l2=O~+wkHXM2Z$DaH2^RUVq>lEGjT<7IDidMlsJ!ft_wA|)g*Y7kNtcKZ;{aONG z=VW{nJUe^?#!2XU>Y?Qu(C0L=LVE36d;GE;L@S391LrKxp+LubE`Ib0C&8B#+P{Rf zpFu>Zn``qhyqB1}K<&%bHYrBc4d(&luegAHg-}*S3qhZ!1dKcex+&@>XoDS+Yx2AqFP!F~3phH*=f;%)1;s8C1MeO4#Z0LX?{E79~_TX&|jE_3> zBfF;aaa=umn+CiFsp8K=m@po7Alby&W6cAt;ex}iHb?CS=83;bCE*B3oSMLSITWz2 z|7S-m(&sMMA3*RP0`9@EeK@+Kg1Rz+T5w*PLcUC)J~#>pv;-0 zHKkd;gtchyD`A^#C`8Sxva5)Pem+Keu;%NLk$)iT9dDAX&KmVJ=tp^~lPC2u;C|~T zFOzQnT85Z@#NFFHiGwiwGpeBX#j)TH!Z0!RzN(GFBRzSB(?=vA|U6gYbp81}^hR0%-J zJP0u9ZM>20TXRWSnoHTKE`^nA2^$gL*s37>EWUK&2xh)z2g0?+Ex9 zBi7H>Zs_MZ!<2sh7Zx##eohF{&re+6Mg4pnHi>Jcp9kVVqps=a>eOiKXE7DJ-#>23 zj|TnIxid zc9q&0wks@0t{0#`Q}kiWJqi%yZiUU45HPczJJ_Ud73hrKHn$PBYnA@Yco;42V!gby zF}8RZsEZhEIcY6uRKch;Y*&N22v4@ceq9bYY78vCMt$s4VhdO*a-=LAVD-iqozghD zytxO?@AM=sd_qxi@8u>HZ?W3XJ+~N>l1cGmS$wSjaaDkd%{F>}Zn1LV$(l;3QR1J^ z^om(0TNT-#G*DT85dAHiENe!fAOy;k5I3$C)#pEM&}?8U+#E-dgU$n&od1~Opta-BX}YI2 zY#xyuYj+2^==CF&EUvvmanTr8s>wy+HM-O+?=|3V)W~rhsO%U6NAu>Kp0Oxz+oP(y zQ7q5Z99rBFMiRR!5*(;!qf|S5*6z^>7r}XHuy4!u>KRIxA_+$$FLAe8w%3hmXfXQ< zv%#i6W)KDYQH3x$8!SkalMEyuy|itjJadF1%A@(666JieyjQ?KZ4f1nL|ONUBFbQv z*NP~T*GL)T6YY@IxgGQ zo2vIVY?8;pmJ0`(W46LlaU{?;K<2udDH`zssAC1{h56#C#Oz2MKr?Oj%j%%Sefr1O zaq5!Ia=c1gV5a>VVc#n4VdP)y6~MF9)PRj;VnhW&s(29Os%j zR)prr4&UHDbe68ysomm=VY^*UZ6jZ~&{f0y&N_a-1E=!MGl2iL5~K)q&)RM)HI}@|7f3!UkQ}@v$)5=%Uu7mw2qeECklbwNTLQ_QW^zU} zD=s=Fkle)GZX|bHDTpKolEwy-nvjn*lb$X~Qvyl9lCTJh2)kZ3>u!lo2X8vbn;7?E zZ5Ql{K{+*aG+EMeFgJ3cwF=Zi@?Q8(1HoEr8 z`w6*?{_$Z2R=*!GX{)yCA1%&4i^CG^w9>Y%=op80Mn)WUWX260K~SPefF$ez6i{3u zqTbku0TcqF{XI|B?JY@X=_I`Ge7}Ew(08f2Ri{p!bLv#pIj0EkhQCP+WYi0<$TB4{{(_2)NU3LkNndoZ z=w5W(JxbC^SFGz&H|7pR64uo%VQO)-{e}9<%bVN5;OMD(?0d9!S?%jBuHGv81@;5$ zu75|sn>QD+CS>{lz(`kLS@pfE?LQEjbT|*xDnB7m{?%<$ete+(eb==K{OtqfKhzfG zvu9Q3+~tPcbF^N~s316@$9g(e7>X!3gUjie1A~*ipM*qZ$c0qgAaa=1w z)e*9D2GwdQ%4;}xM8dZ1#H!l-()QlLRqtIncq=p?B(wu6Yo zZiCy-D~uaP!?ppt0d&&!CS+XC{R-IlcoPo7G90%>fD85*6#8>3aKA_HStMKlhXEv4 z;*};}!LGzRx=1^{MC?&4(|*c@L<--87NCajQQTzWO4p*mdM*r#0fWB$TdQ5pbxCZI zz-dGFqQ?ZJb>mmeLd+i^s|D*3d7Kk!)qfFsX&~4-rpwP%G&)`WJtjN3=ZA}};2wb3 z;>z{-7C|WYe?|)BSJlAqIUSSf--*fes;+YQTyB$M{#y6oYOBB0=ALe1xgv^1yaM6t zKV1mIdiK}qi)RsKeIojK+R@f~5^XKTuSM1V{7h}+rf+LGa^-r@?x!VkuOCqtxwm$a z$h~P~bmYF=70CSueqvegiH^@`^jdyxOYxaUHD0q6)JEwyTUw4%Nw4{TmMA@$Sr?_o z&Jv}DYonudR2QK1di+R-irV90o6_r4YfJHKK|F~+*G8^;bIXw{>Gd~HN#s5^tS)k2 z=_rx=f*KvU$2tMIhw*c2=rvJoDL(TSl3o`*RU4(h-_&xHN_zcpkwmHOnz|^>HcFIc zjfjrY7drx_&*7&H={1@>ST(%G_$?xSQ=hDj-(4G83%`yhBz_NM)WvVjX%fHHnbGk( z%n1Ar#ZMdJH`@4>o!L_SS`xLLPt->4?DCc)SIUF4AD75|!crHxe@l?a{qwNs$o=*- zAa@^rPK`V;4r?hs^QuX&d5_md=_O??N2!zt>mHLR9XzxyO4WFYQp+{bQTlWOQ2GRZ z+K^tO$%AEAw-~=Q#BY+fHhxRjw-$aUdnA589a0y+T04o~%8cmv?H>>PUXGtO#BVft zpk}lbzZRpm)>9j~cdctVa-}@z@Tf%Yd^j=4O^ncF!4oHu>$F5i?(TL#?hgFKAP?$2 ze+s5jVjq}fg-NVk`l=c%0*}&HwJ&C{QG8W{t1)sPn8#1S{wZ5~@KJ$6Sh7aUBx^9~ z&E4YLp_$l>&Y~GGcFSkom~@Ks?P~j{hRt;%T~r&IPe!AUl%XxAkAd0??%MQWEom+K z`1TP=9}};vOCNXp>5})ccE?rG>Ej)GbH0V2R-=y@_0dP=kQUI#!aVf0cJspMB(l-B zP5a&t-#HCG5F=ss)w(^Turnn&g(xt>7!oTEvCxN6wyOo}4Hk>$e55vg^e$>G`Y3Zr z`WQN}E`5yrMbgJLgQL^OUvLz!_7r|%ppO{rNmdPNF~6C#-#uI#pC7;9TKJrqFY)=q z@9X08=bt4$k6alYpCf(&KCi}4>*6z-{xJ2*mf|ySDVeV_;7uPi(RsMA=qUa1XQ1>4{QNKI561MC;@6U+Ii0oXwfCBqBUkDVWearv?_U?W zBaiF+KQKCS|8j!-kDnOmwHf+D(G@KiAJkdel@CQ99H>8397i)Y;YSP(t*<{s<3H{9 zEv1h-9E_cZqY20kd!xx7zZbgnrh(fM|2baWcwVWYLjPs8WG zhRuPKXzn2Gk()6dr=|)##yFp!s{LEK;zym&TMnZggYa{T$K&kFTS^~!8s%BV?AkOk zfh-vhVT^{!`{dbfZd7w#cSADJGVCAps2lg&z$&5CV3v;OwfyMAv;X+e8boXqt= z^u}=hodr)(XP3Da<04E5#bGFnT+`c?Lu=OFu1WHeB`+3SRIf{1-~*q;5R@rK zGz41b)t}Mu-kXK8w9@V{aWCYpID*bKmGv-yrpc9!TOZaG3S?O_|Ck%-a4zyI`DdX( zWaN`#@=oHVw<}d#ycS%JiibFd6<+Tup5dv#h~SxA17+SkLaB%A3jA3;ng47Hjad`p`PJo740FC&-BR=;%Z}Y5>?OReKU@a8w@f2j^ zDS(vUqtk-)g+-30(3g@wh9$U{h)Uf@bdZ323!tF?0+1HyB|Ac&kRC-|?Rgv<^>1_Zup%ZaqB8wp^$bqV@P8yiFIn9+8U9_qy{c<3Lo!t` zD7HK-SQP9x{~d+Z*D3v83hxxh?2x^7us_-%i|7v=30b>8e2D%Pr8D|4I7Y3`7zX#L z`a9!AM3kxk))4)$7nsa%?iy$U{H*-uFRsE-az=_ESt2-Bbh@WtQdnoX9}ep*5A1=P zAPm4&EXgsn!Cum?>x2>B>m|Kt``&~`;Po!&{n5Ec{Z;po{(^v!#=u%r2^i!1(PB41 zPBW9k#8q0;AavXo2^xt<5K$m5!WPO`=3b|BC-qBA2lr%S*m!qrIOqz7PQ z^q_(~6ot?#`~~}VRa@x;)uaMutO+Xs1CK!) z!vYSW7n)YU;P3+I@1U7cfKK^UV;L~7Nr1e$E({RSk}cRItNKrq3;0ub0o2p9$5_DC zCz}ev)!_vQy=Ei}*nt9cwg&|)C1WD`BQM^BOX2OUQ(4#7xAGj?u0uETA~L+&i%{%( z1u=Xq(Mr&^g-W#(nb4U5Lp6);A4LJju&<{1apEHW2`;d0~N->gW<13x%9A`o&)2q|L@GTGWp(_zN z(XEoX5^h3A1iE#%=+CZGH;PVS2HCCte<9Qd(HYV|!u-+npu4s4scQ^Wc)nmz>IjAB zHN=Lj9}f}no*L-?81P&9w-A0Ws)M3B2!BT+{CRv8f$&3b*G72mThXxl6EGUU?rcHp zT{~^4h27^7!DMr@@c9I=7m3gD16qnteazF^$F&ka>AJyx5U{TPo^B>R>B`d`tyErK zp%`i%y#tC%ClsuY^7X?j6QeYy@&40C;%i*UebGs=ux#$UhywH~Ha>r{iV> z?qe-aSt&G}g+g=BFLGQ60b5NVpF$9v`U|c9ZTNad1N@sObSne!@AV2~T>C&!m8!pB zsb@uLVB`4LadgqI(Qwo}ddw@N)@J~obX|{v?&xnrj4AcJb!pKUlhrj(p5}-OQOy%| zsu>@(nx@LrKzG0JVu(K9$%)qWO`y*(w0r^R0`f|t<+apkXlX9IM;IGMC!r24fmf%G z_9wwlI_dvKmQ6)kd*!}pxCmQcZGhgx$z&T^ABm48m$w)nVfenUPWeMxUcQbKC&SL@ zQ=Pxu8x1GTqmK^&T_ird3J_!FSo!k@lo;8b!oJbk zqkDiiWIucs=&wF|g{hsyI(5_Rhl?PrBIBny2gc=EG5v%S!d16IrJ>aVr+A1{>PguD z=Vt8CD}Avu?-&JT)MtwTR2=bb#9lapz4&t&A9nTbs~ zD@O)Viaaa1@-$LQ)6dFT1`tF?uMLYXI|okB+R<&mb^|9-4M$W)$)G|0%wg>A97Hq- z#qO>^`H}p&3c(qwwer&he-=6Cu9A@%$^)w)!SyrY+Aq8cibNp@!LTkzCrZ_`ijboh zXP*vAxUnbCKX@7w>|E8ecqvjG-DObj9X& zdwxq8=FF%wY&|1P9yJMHk+B~^b%}C6PspXqA(zmb8c$F;Fn~N^uSO@-AnGHI9nuUG zT-RZLMnru~^j#H@F?nXuY9;?gDPyv+tQB3x+$d6qCx##GM(PVtsoRb!GEG9*+%AMo zW+jp-YaY3u1!q>^_P|r(c8}`Cqgc1(DR;V{nZHm7nsV;DdV4-Mkb=gpQO>+H6mkZ^ z*OmO!S+r*~8hE`CXVN%2YBNU(DKjT1Wxyr&RoabnaDdYj1fcAZB3#fVPyjw4D~ZDn zp5qwjH;-fxZLUdJ-Fy7zo|oXs!%!!W2%f{CHn?R@oBN$rxafu94!2yE$GRc9BK+(; ztgO1nS5q=bp*5GSScP>}P3zZ=y@SJnOqoRi8KC3SIoF6$e)GbM zu}17cN|@MMzX)P$=q3&u_Q{J=Y`)dftY$nE*Y11aB!x&wf%;1U|t@3syMp?Mz_vcCS5^K z)RtVYlQ>0^P|&Y|%xtm?hpwRi04@zG==<;>6!b6cQb8YUsH>pgxn3&hm8x%=RL$R9MNRWSt7Mr`uRG9cny%s|}^m|qx^)Eu$v^?<Q(#Gp zwESc$W8!8h)4ch~;)>aAk}#l!0U6$!SHC3(=7HKHRY(08m9g%64#&qVOwALIDEAFZ zwj5FB;|O|a`WQGLlf}3;D75eDdSwr&HBXlqOC0fOdwp|NPliz)lrRi$9aFSFWQLR) zYfmUO*$&6JVt2L`&e?GfYX*d3BY!K zk)|oO&-OxcoRzhwh(v4d^x?E*+LcYo;ju z5?%qoA?W(X2Hk0I2BBkY@~Ytyy4isRPvA}{$N}8_iiq$McfvMKG*sd*o)G>)`wbz) zIgsejehEf`IQGGtB?2G)C4j);%|YM;%Yz^?z;@LO_`w;krVLZS;O((wv7iuu@s@BH z_gDQB`E@K#VoJUnFSPjxM@Klz3Fjohp8KZ(QRWRxo~n9mSmnez*A!)!wE3&rtGC2z%2*?b;gM8av2OrF>DRAD=xGH)%Y8g} z;+d7loq^7B5^e4rs+6-*Z0=d9Jn`0oIq4(|?^IPSvt$WYDYHzK*StJb#Xka&R^+=& zwzLL%rDUJ3gcM8z)=_;!0_$4K+6mnpL593tPcG-gMd;;yS&+Buxo>tbOi`74H}D7j zeNVA4xg+P9SdVyO)N-1XOLkXMB>w~PQX=3+3JGX}lp0(f5l0P%uPk3UAA&}lH%RaB zfK@)$g-o0y3!~FbqHSF9G~y0z&Y)7{cFQvcxv|WJD_jW7QY>2S z(eC=Aevn|+`I!7gEO`bbc}4dS)dA4kgXW`RC!3S zJ^;$~MjUaSoWdEa81zyFDS6G^U*(`@PtyMU3C6s+NpZR7V<`mYtHTp9$IeWVyI*n* zv}!Ou66?pqc>_j4?f5!j=mbHsyd9P&Zo$x*7Ln?Pt^_9)4zx*Ui0tOY4~Y429F7{p zeE2S`;Sd5YBTs+_7=cjot0=KNqj5O5Tt@DyrC!C!u#&%?g*ZRRW9g2w@TC^0OY`4} zCAd9IFMEXPAbBk8fUD$KvWju#^LHUZ+l?@Gfla{GX1sQ-!)xWy_wkF(7mGkiD$Wea zo127hUdC$(?aUOMPoApuwiiK2X2Mn$OX{p&d|=-B@M*BFt;$%{i8Z`Xksv<7$qROz z=yV+gY|vey>RZ;c3P1eI1A8Z7O&UnbOcgkC&m9=vFtB-&_LPsbm#MK^s@W|<8tOY# zRnMawG5!N-&_?wZT$jk@pv)A}u_O6qDyW6hKNHRG21XBoKM-q@M}4!s$mm`6yfr$-Y2lU=|`9w7Os&=@&wI{95fiTIqQa zrChF%n*by!I+OHeksnoQ!%xD1p>ycGVtG8Ts!J?nchJ~6(?Yq`SuG2WOEgVL#rn7^ z>f$V*3F}k$pFuJS+ds||F9PdR&khQa&O9_Zv_56{NsRDV@%;l@(eVE9i$BLpNv5w) z&G`rU25eLB{_zZv+N|}dS8ic}a{u^^Ir6^t`jpO(#`^?6-VdiS@Z-nllOOf{<1H6A zv_7@u%HaA`9=j^Ae?0T{X73+MvX9(9UaGai`qbc=ki0A3B$0_8;XVvhXt5C^uwzTk z(!M!y%n_2cXVEgRovrRwX)1GVw#05oENYY$=B-1Xx(l&}HrA9xT0-1Aq+ z0q&gd>JD%hzaa;>o(G#Xz=hV2M=b}Mug6dG){jXx;Ljv||Efv+x&8E7{8=ep1o-ph z6(Roo9BmKr=cGeT=g;_>NdElv?+x+iE|JK>r!gxc)P) zVg09bNdM^{Vv^O2+F!5!17Xk{{bz23{!`Z4`p>=?^dDib)GpN?lf0{La01*eKnl-hi4^YnmwmEmH&OW2IHqJhq zsngTeZ5MAs(+9(iLH0>-G|oO_rwH~b!JZP>=Tgky$iQTu3wj3FXEtkym_8gzlk8I> zMx3zeLuG(fwIUo4S@jXqBc=~E`t)HoquJ+kol#qi!UEHW2YHBqixUUsQEK!-<@?Zg z0rq(wucJ;MD)s3@$&)($bzlvXv7^RMU@>CH;zH!~!S`UJbBAE7#N1)b7lL;xIRn51 zB69BFyFyMJpwa_tx=Qa!_+y7y$k_%bnMTQZX0;^eVZ@e*?<~%Xk42wvXgwmDzP4;% z3-vY3l7&HTiuT&&Xl{hQcGJ}6>udgmFn#SX9)!MjsIS!5?y9V-ujRfZ^|fjHW7OAP z{3nLp=kU|^`dTkEC}KX9DqaNiHCw+BFZDYuTwh!MZPWF&Q3oUSwLS$DB+=$mmx$D6 z>1(&zS)iOxO}tOuN10Epx=jeEULAyha(APE(&tliF(Hq>-*4|*zrOaH(Yn5tN_}l$ ztomBk-d51p4%`L8nermJJo3YbWI|orUl#j#vuGr{FB1hN6igGh z4XSHZ6GeC4NGn%w&v!AU2#hyxz`b2ASJfSF7F^coc$0NggX2xdcERx`1%(C1n@;Vc zj5iA)3#7W%r19oP7_A6>M~pW=7PjVi^Uas!5yk1x(VO_6*>Bl+a1wB91PSEpr(EbDT`~@5! zqS5o4DUH%|9!|_`Kz~YS*nX)$WuveFJzt4_uUCJ1^UG$@^Fs^6={fnm)}-gNb~TBf z(f*MBbSdKLH-9?N<+&!%^A+E=9zDwi(Pr68PW`xr`cueWa@r+mZiN1HCZuh1*SkJA z2~^|cLpWaNC`cv=Sb@itK&r1ENuNV@WyrC0X@0z>>!{9{xw7vfHC1O`Z z=ug$+ML>V*a&d^44x;U$^{$-Ho31}~|2k5CIv`9Mqv=oIh}353Pv;HeNGtWH3#Q2X zDEiZ^Y$2e&{005z?Plu&*&C#E3(8n7QVCHRXW3}FV z{h!;KG~P@Nj5pMu-aQt}c=PPyCX6>_m9014G~M3zep3wgW%qa+voHJmlyLj9X4tph zW{v@#PjN0?!}iFh#|lrkq!{3_H44wXJ7dJpmlwqV&l;R7*D!t#PL2_tUa{Cq_GuKJ z39-U+|KJ$#^DK^rYZyOU;r$qseQVm77~tvBC_Goi3QycEF~Bp&-57pezdc6$+%_o& zcn&OV44(62g{OE*4DgI=6rRU#ixEFh{xJr4HsdI}hUxiNC`d8U^9|EufM;-{@Z1|K zJjMAj!1Li812m%O_)ir$?jkTo)@m_x~*hcpk%1cMa3e`x9csPsY+1;5mt- z;u?mhU##$?EsFu3dm4r3@8e^{&;9Sm0MDm5_pM?4oE|GYy*`Tp9$TaE%o`UYe%`E% z0iHDvHHM#qx5fz1Azuve^l22H39-WS9aL!!A{dIn<@T4Wj0MEEa;dvZEaALyG^Owc| z&t{wx*D!v5y*WmBZga%|&)`Pkxi?mLivK4DcwR+p?S}F5`Is2-lcB``Pmf07xh__C zdi@XsJdYtfcfuNy&eNRT^fbws#xK96R~NqtpFdmX82prnb{bAUeAgVKR3J= z13U+C?p(wDb$+byeEEJ1@QiB|p2x3`z)yH+)CC91;S3b;jD%n7{6d6M6rR^BlJuL` zO#01B*vA)1ORBLWLhtN?N_jKiTcN*k74b&tUASCcS1iSq2O!}>?=_p-n-kAiUM_l= zs_spQ<&2~_gcO#S0(p5_UJ$d5X$YXf%aii5h!;dYHK7D;$wS9*?gKo^nZYA3kID<; zmWX^c*hEor&@Da+%5x)#^DdV^mbLtw1EWA?Si!$y36Du zv@jcW@$8sV*F&=1IIUVBP)7_&MH@1YO<7TmduL%Ke*G5Z=`zdFFD!Z6;#Aks*~w`e zN?nK$!Txxh7x*Nr1>B53tU2I3U3K7W_z|7MfQg5-`K8#Db{E4yAh*kE+6wvBiVF~@ zp={OdHK7QryITACOvFTT<>?UVJyAi3JkD@@@8PRieC6>b!tja6#06&>5oDYDCB3Uy zuIet}1{+=yQ^10b%vpE>1QMAA3uPKH1OzRTX%c+}OZoIJ;O1W*%3FpPmW74oVehMl z;(ZZcIBVMHUJQT?07!YqwBT?~E^by@+BvHoQ#w{4%7EK-5O1bc*YUt@cR1AFby8N|XjGktr>h+|Dz1OB zL{;fiuDUm>#V3ZsM5p|)GOp{)FR;03Q~DHJdsSFFmZ$B2W9bHXxEHU+2kZt7VA+(b zOX&4}BQmRH#q_cPsh!?!w4M<|kF?0Eql2KNVHL6U{j=-f`LyRI9=@ z(6vyKeiG`yi3Ea4fN7%L?NSN7d&M3++0v^O*V(9mpUBh-T&(G(iYtw;-Ak&O+vCl{ zYqG{-aYc*<_{=+!lXTX8mvCjK6={S2&9at|JaEK>@WaO`H+Vw5*`lP^EPTRIYVze` z0*V8~TaUq-(b~rKFO(EaIM)KU%SGeFH$Egu?u+9Gw9OeJ8eV2~{u<|)CK=FO zgQtWChFQI1FlKL8onM|rMB6ip(~5AuOnAKXDH%$)_#*xegTuSCC|*r3&i&5njW1Wz zH##m*og3n8?!#*Dje5Ljh8h4t78zCl#@xf=DF|pz30vTrg$4?^ZaFio6*tL5gG|gF z3LJ`zZda;6R$&S*!J;l1iZ(C6_ab_RUg!sO^2fa>!(BLuMR{B)WR+Zxb>erwX->cI?dPx$#`nj(x#X#WY>#AOMji|`xfn!`#q{0+p3fPiEY zQt`wg0Z&DEBEyqW$S~!224+)=@nbH=u`%8;NC1j+`S7e;(Ea_lG{OuPEwpTeT&ukkwH!0 zsyM&Ihw7jeq*lc_8euiTiz#0sV-i6{O??jGfux-`IL+WV*YZxg!R>N?hflKk$wM%# zF;um~cZ)!vftQ7R`8|BhBj^cWElDCcxLJ|P8>@0}DKKz*8PK{OB5ND|Bb156CGqpq zVP)(wLR3Kf0GzSA___!!PgzgsRxAY+hclINrA#d2Pca`$cz29HhnTkp?me#!%TX-HFHZr-6T7=oyQwRB9m75o(swG(naHiC z?^2vM@x~)M+~yg)$L2|{hMc0Hunk&4>5+S8Kx^=t-+`*7-T2v2KOOwRcO|89XwaxK z6hPXv-n|w6NCh0xRCjlrg(m>9xrZ6ymp#g8LK=IT_lY>C%9y0O9mW(;mRI0G>1H#E z;2-xG2ZGGz7)AV#VMY}X!;D!t z397<1W&2@e!cG=_7!MUJXV+n6{JJSh_j3HNn4*l`&RPPrm;H(A7yJ@X9XwJf>%{9E za5{u>p1)o0gp9nwR~Q^Ov#Y?B8A`wPit|ZuBuQ`!8nq2g(a>-xLU*k1V6fiNeW5L` z1TV?v7-%Sl{QH<2AdW2{)BC0d&odNtoGT00Ggr|$Qn?_XY+si<&>AyP3-p47tN8N+ zBsNN4ANZVu1NuPJ1f*=IMzArY5p1MJaOds_jR4*Fq0j}a$~CBdMzcRQpr+fwpyRdx%ji0DF+eE>w#Slly@EfLv49U;C}AA8!Rp@X5ekp$J_2~V zy+$exv=5((@4LkxLILG?oVXb`K-IVk0X&MX05UlifS|e`f)c9a|DGp!d9U-UgM=tk zoNEvR2;Z7U9fAY~-zyW?0~`qOQT(B*1bi^Ko#OIBgS8hoi@*FWOJKm z2`dxwhVoO%7w2<%3abG&Q~?g!P>}`o%HBPEHKGL^;)oirJY+yFj)=~ECo@*#-eB#$ zo!nzBE5V^(9w&(0)X96J%@p=5u1y#8g1GU_BDS&yHIB|!e@ z$|=g2Z;`&2>AxcVXFMFl!$cr(^yf(Z0(rC6TfNuDXHxOD$8EGaPn@*6H|9Q-nYJM_ zzbHc~;E;1~)f6S?d+^c&KV5*Iukmx=Pg9gHeng?*t}(lCMK_Q3smid8ct-XVJWs@Z z$qrOF>N_N&ZDWu*=aYj-1!O2^yV?TuUhi>3`P?%PUPBiternnv?#q}lc>Z;BC;>@TrUkdO_+Dt*W*V9o| z9DzST-$Q5~x4FNgC|}qiT$HQsLRO?W4|YN(O7x?=krG`EN_0swg9-x@-OTggbiuCW z?S@3hXokRLw|CvSU5Kf!d&M=xE5OcHum`v-Wr@x%AIo>9m!$X}x0&hqzYU};hy(Xv zhOxVY!1_?m)}Y3YKwODZxP1x0`ahtpCC8-f)A<5DK6)qk;zPXK zh%wNbzBLzIbn_0LgKM%n{qfT;0IT9;&Ob|G-qW!y!64>s`_nd5tw$SiAgt9{c~bQ* zT+WdiAR;_HF8diCDin_#sQ4GwEqVOq{R^ikXZ_10gwR=O$O<@1enD6N`~@Dqrx@YG z*Xn}eN{UHm$;sTJDawamfxSNkdw=*T@Hg*cdwQ9}W^1ozK2_YYS0PpWs3Y|9V( z?e1cf2$+7Lw2W-eTY7sQWP9Gy+jAwr|B>JW(2|p)QR;#cw!`WyKA8)4`|vZg<14n~ zE4E`F+L7BW(|sbtxi1co#bw|)RNpk{$5!_*s{0F_BJFpVqlv{R1Wmko@1jg}0(5|L z(6$m=+zzWBtuLO%vc6^^x#s}U+2AQt-;yn4g^8a58C463&pAQexQ^iplJx_o6(t~iKC8zj3EBUU>G2UmVAbGr=Yt=e~X+PJJ7LX zKD9d6wo@0BRukjtdvag1yLLEku;0CN7RRm0CbTQXhx`Xn$WatRERF)2Mjye)7!OiD z&&=PTb0o0iNMhr@-b^?#v=dcT&!Xs14!pE^7wIkP`<~2+axTuoEi1t%LH&pHf{w%x zSB9Ta>!`u42Nb`?@exyk(Z4Xur9k?a8fN22D6>JsAjRU{7W|CfhAg8r=Bq-Nw6iWK zku5II{hQvG8Ar?F9R2Ng7t59#*zy7IZ6Z*kegp>4Sb^Jbi2)#b^w+?^H^_8=g5wa1 zz8x4(8DbRe7{`00n!mxP-%#!!({qRv^ojXbq~QMqbjR3>_lb&bJ_^n_IO8TTq4-4g4)?P%=&QGz7m1hS%!|b}1;efRJCyjH>io6c16QE3l$|Jsa)X8L z%5C?j3q=>+a2Q3JAH`Qd-B_r>V+XkSpiJ21>xA4pL;+_*yEpFzz9)b&!2ZLOexK#` zc#yj2_xKK|MVe{A?^rod{*&)~JdYL6GRF;|nz4N^W63Cll%nCgao^!*0^l6mw|b-O zZveM-h7swQ$W4HjLc!B*?o|8~=7DlBab1K9|M-eN_`H_u*FE}`S~1@lWRGyIO3&G|#~@;m&V4upVP#U=u- zVpjoIvCV+1*muBHY((HHb|!EYTNSv9y$i%1<3hCz2su7uLdL}LYte0TF_FNBFDAZG zPP}5h>@r>Z^KY@f)Qgyn_~5FD4=Tbw zxSbzVi((V1nahn8lLD@eXt$>#iD4-)L(B~(ajiug!6Ys)BWVYdus;DLX_xc;Kjixd z_+FGP|2=@FSUR3v?@8Su_GL#*s^F{5K2JpKV1?Ld4GTrxXy8Q$K2ik|#I6qvR{ zE597CuEMKl#H)|Bcle?&UM!J$w14tNk6;;Zu#B$3ik9NBe*d|!^M4l{AO{CRy=c#NzT3f6A@BbIKAIR2&C^td?oKHy6wg-2Re`vM9gAi^1Dh%u`HAV{e zwH7Ia^tZa_rN&MVO~Z$>a!DRXo-n>B0|o+gVG?{%uqMHf_f_1&B~Rc{?&O_1<< zryf5*%p?vJ4h~>bapyO`3~YqxW%hfd7bg&N>QGYqyp{&!XYaO?pU(pwvaHf&Y{A(e&}P$?}A4RaO?V;XqSu_3thq zCWNLzs}itq{%ss~G8ZDh5HhsHD0R^gV>|x5v6~qJZ#+4~4 zbqs>H(txQD-rB#DV93b(CT`fm6^|jmjzSM^1qx~OQtG13%PB)j<9jP8z3Z_$$^cOgTUbcbBOT8)Khp6` z6C{CW8X=Xs7UB1l;W=V`t8sj{rTho(YRUh%|BK~+)8-G-mP=cE2ErTQ{A%_*XX$@N z%LBOJOx|z4?LT-EX_XLI18}8)5>M4UqnY&5^zoo+`0@x#j3BD0MWqTp6!_unOK9D{ zdyDuJQ_xd{m?-DN$F6TT4IcrTAtE^8_M5Xs(*uaO^vzSoLdpfLDzBT`{#j_X;F*bN zYJg`J3sm%7jyKN2sR6DU5|(3?$k7=&8X=I@nwEv)&!5Wi3|mv2Z)g{GI11ZT&bKhY z&~y?jW1-_uxU3jll?X0M;3lvX!X*hjVnqVKSdhRk)+6v6WjR8($7*~$X}Wx9^7zfx zVZ3Hr(fN6TJVMKb>2K&%BIHEp*Ms`vL&z@%VOpl1BAWPL!4KO9y@u@tb%>SH7ftP8 z{I`O7%fclzmM(SCRvH&7EVxiv!G($nE>u%+Ii-3^tIO9W(-WHse6i6WE24mEAu&R= zkO;!{CE^R$*NDBmLSoY3xHxZ!wHs^%l>Fzpn2Y${cv3z1r(%h|fab*htfY*i>*JX9 ze3|ucGV3`JS!s+cvaSLYBHVA}j63Mb=W0 zb&1UCmRT1^WTo{vvRb%_g;F1oS@UGpX%ShM2eQgiN64%*W!9@BvgS|-XC)n7D+=u? zbKWO&CPw6(Ds#4}(Bd{e`b|5U0ucd|THHGe`6f&bK@Grba)>5+OeWi!*mVVQ2PC({IE@=Xo^WT_{|ADH9nK#ue4$&texNr4EFQ^haClDjSy;u z;h^Gr8C_o0AI8=aHv!mP!+?Ge>dzw>X-*RA*=|9o229*RsMRR&-OB)$=1)XtUxa=O zSaple#P%?9@Gn#+yVI418gUvT1cmJTOj=%xPu!VkK(6uV2upjIvU2Ab1hN>Vz;y5| zcJ54*ddG-FK__R47uP2_YmC!7N!ZhN_^!9QVcAiscXZm0z+2;0U2bA5fS zt`1PLn!XKoHO6AcS%OP8-9`a$LOZ4aci)~%;`cj}Bn9}NqQO1-u~3*Wf(p(STY0`R zxuaL+;(PvqyO!Y=eg-6oX_X`i!Mv(xtO;cCnaC8uwrcuXolz?u2BXeo{eNKnp4pN^ ze?WnfLuU#&meh!fq!SeV68fG)X!nBoHBtAH<+v#MUyJX+r9tM^*;Mt!?j%K-6m`b6Q6izi``u}%oug|kEkZ#e<;1fEtAM`d|T zYWA+qjDLTWLhgk2$1Xs~ zE%1aBE;*N>Qhf{kgm(86sDsr1FUTE~o<$hoOC*}KViGRFtd>C5g)-|vnbjh*s<@2w z#?P>M3Mlj7l2ekl0fs*b)|YAPW9qsASL-So9pg{21_X$L)89M=ECkj#U%?vauyVtP zFjRxdb+_&?6)s5>28ks~KGi_0=SB=cI~?EIJ-5KG=Y(-O4S6bX2JL7Z(rfEQXAL}s z+jC`iKLQh=>`_OI@?w&gM0rV)mlSzPl^4j#QlpxFEVszL+xcw=$JthI|059XQx?HS zExY?sx+Ea=FwNXlBhk-ZY=EQvSGYs<`rgI<6!1Bc0cY<;vKKw0-AUf^CKa8-?^{7<08Aa#}O7P>>K;Q z5D#Wf%GmFb`D@i*s-_>#{RT*7@2TnA73bB$1Fe9aDzJ()b{Eqgnh(l{cJtms_<)Q! z9dzH>;i-MIA5$PJubzD45#y`+UaSU=!D%L|F$u+A1Fq z`8U1<)qyt4m4_Cy;sOw85ML;!3c}&LU?Fb?3jv+)!vg?>1o>K^MCq1{wwFI}Q&koU z;d+}y0_xkw?*hpFELaFqI^jV_Pk(rBjQdd*H38Ow-TFf@LLaw7X)5$ys6G#0iTkQU zzkrD(*?_VHy}?iG*vI!kKWqu{K-0w zTANjBw7EFNm0H0N40z-TkUZH2(*{#o5$Hh4PY~FW9p6>lXW?w89wjO*k&6;lSR&I; zKxZ~WO)pnshz^Eqn)-ox&b9Mb&%WaCP!3k1Mb@}qJqfqQMGTt#VZ#4#o@j27!>A^h z@S!h3`|J%KNQ!@pjfBqmbDZkjNh~d6*0?>Y9}dICYR8Y&;;JM^hqN8)u_M0tv>nHe z*xav`vvRkKM21l`hXoXWl|U8o6K&K&G4#&jOOB2&lFBhhDXu!-mUc*eclFo%drTXV z{eh~y0beExwe!@pa_cHzx3n|gvsOH9Ro(#qcNu7PZ?NJUD|!|=ZSD;y;XE5Gm6bQj z!8Pf2_eN{+cjw#FHX=_y+9qon;$H~F+1#I_v6IjsIMu%2Cw)Wplr#SM zk+tyO8-`XUUXuMUb#;#^Z}!go^p$kqt<=rfa_@*Zs1IuKz61zbfeZK>F~QyAEEq8t ziQ92!Vd-kga{V6OfJ-X$dz}BhiPa8r5xGkKwG!bhI<3g&b}=8k1b}?`^%pj{u8Vg& zVRQ~@2a|3{Vb#5YmHIB>egeqK{i(Hhe>XMln40#9n)b7rw#%0Gty)~t7cTD>$NAPZ z?{@lS@XpfH6RpY%>!xq9rX8`SePT^J4qW&?fFj;6Y`)WYdEHS;>Ga(ZrEEYcDRTX> zQF!?M`g*?a)3ooQ_E6X;FBDJT83Dt348~J|Q2yaI2e%x0aXTyU7Tzns8_;Lq#j8`g z%P5}&i@04T`Bceo5urgo36TZKBcBA9Vu?pSWyxMll}`iZ(?I#ORF-1l6PQq@sd&Pi zFN-hTE2{Yt+m_-XRa406@>{2M|ueFXu^#p%Hz$eaQ)VJjfa#r@_&EDnnuDok_z z<|nXZ42VQg5VS1OwLy8h1hByyq$tB!k$XNWu(h{f1!RMxqw2A6%^>%nokqo2#E`NT z-%+;ap<$4yEC$M&K$d3-Uy3Ch8=ym73;FDJEoWZuYec-&4`X8U@g zlnP`JDf;-cR!GGT$Zzxf3CmP|M<;99h$Pfy@UhJ-kbkOYkYc*V2ylw<;K0<}SW1#P)oCQ4dD@BoW}Sv-RH;WRmmFMHP@q{2*f zq_@j_wfM{SYI|=EUSoYvPKvzo77F!?VNqp|JBC{L5ymwozZWMX;ZRj6cp*AA5pZS5 zfz|6Jr@_g{4)w9>DAxC6y}rlMa>Z4RoB&Vc@w~*E^>nPIiBTW9u;3HXLOI^J3rK!S zelosE+n{~EK#bn%`+>z3C4XW^;-k!p(;ZCIcRBk}@@e{H^H^#yT`>)-fRAan_-&Xb zsTgss>DzMm(Dz5l=PC)@eDGjaoWDasHt#jYqoN0~fQ_@JIWOys|JPs=)vtU;N0c)X zDqFYqs`naGRShZ$;-&^YW_KSG6G-|v**!DS?MEF(YuWH5)B}}i1l++z;Wjst=bK0s zzePu}OKsk%MqlSCi>%J|iB|tM$GKML+!}+U+Ugk}s)JPF`%HBXH8>v8D~8^|&yi4d zKWddB6+-AqNqFU1*nA_-3=jxs}_XeH^#73VW}k5*dkp2_P? z*9F&!zJ#uVbzOJ^9^<+$0=jUW%j%i4-bk{~e^Fo)T%lbupQ}adZSHS~PZym8t5ys0 zIwOADDtrX?D4@irkD2DX5X+E>c5j!{tRPwHiO1u@Nq#DDWXJLCb_l=x`S{v~Nk6>A zlzgXnxW#DoZEFoYl+Uv5M0|#EE zgrJ0m#C`*xlRWi~Rb2f!R+kCU3+540O4K3cELM*3#Drn)8mzveFN})w13Z&(dO_sE zH_uO4x!m_N?*vvT-9~=cc{mkurihFJ8}{G#s|b=*r4ZXErwm$ z4l@bFj7C{6RT5-x!V@^Z-R9n|Wy3|M>Y9k8aUuyZC^l+WGWBAlVx{f12QeoxAX-pW zclqLNzW4!$&R4bPi@?&TV(WE+Z5fS`jH^G{{oyp6?brsPf?t#PO%%UL;x|S7!V4Iw zSad1IqRYnIauAUN;VHUoOXDoIe{emTjO48ZelQ3Kw8O)FddIY*@ZSUh8r0z8lC4)Y zxa5ptb#)~f4*M-!4Pj68+K3sfaE84ZQHM-D6#Pt9FuN|M|k%@TTQmg==v^a8k4O8T;x`d+vqfh*X5LDnsXRAc2O!;X#fk zaCLdH8X2#Hm=E>J2A~vRvSHf$MD@Q=4hlXEix23ur*L`qaulU4W+jgyG4$;;apE^4 zgNV=P^YcGO+OOr%QA+tN9Oe<+1(ZBlx{|w*TRTAh~86zX(Q%mUnu#%LqVV$v79BxBFvmh ztnR)1q7&Ri9X;$`RSb73c)ee_-Hjcf@|l%ztu*@DV`ZHg>AFZ(QdvatjAyKvPPsp^ zdo%c9w>Uc%C-P^28t`PwsAeAc_0IgF>Q1~1;qv$_U}^|SJB3XRa?M?M4|$?p$fT|j zNmH1lg9za5c0IxP9$Oy&lEsNi`>)}G?~xe@Ol z7pI07YcrX2dqk3lNjj+6Ouh!m`trfyD)U!9r!VxKs-7&wn=lvWDQms> zS(ot79{EPBA^v6tF|<)LFoTbL>P@&zyerlY9LKxeco!)CA6Vn(+Vs|+kig@Pk@+lS zrpWt%nGxxV@6KfbI>0aU{ZoNLs?M#_p=CKQpd8<9y(OP$PxJAQV9&e&pknmb=J5TE z!3NKO6P|pKWUP&2`ftUns+lBj_s5u@vXB4}cNvfRtG>ZHcCe5U2eG)+8BXT#S?yAW zwH!5WE626;(p)af4*3nVXpG!t+BfU#8O{;@WD0CZ!bWtl@JN^SVvOimJ?ocXM+Q9z zn#v$(fwBFN;#>^F4-oVOO1ue&ATI|DgrO6ib`k5~_JqBBKzyjL#5EMiNcCpKxl5e; zIv}K5hw0tHH3>_Rh0_bf69RSn#$x4&d&^9someR8@HI+ydng&}INnX*-PnYKFyRqv zyohGPH@!2nP&_4^>dir*qai(E`i*JOQJBvAI${|fdl8!)=c>+m)Lm82RT=)>5D7Ff zrG8A^wJ--?3+ux#7wU66=&&r(Z+-*CKygSB#Iz!n7iiGEZQhwiUq`DbeJeF%QM%Q0 zeyH?-W?YzrkAz3AGf=viIgVz@P7(G(PA@IU=QmG?$doEGWr-qS|5lhSGNngk>MUS% zl@kZpdPfc(d+hNR;Z8euMZ7^f?rMkO?HDq|X3G=@16grggT2Gf^oA>!_Gz=4TS)*JY7$nv&l>8c$Ve`yDCr^OF zy`)Iw{8+o{Z82L?@{^$7SrO^Wy<$1R_6|qIuBo`iw$0}ZMdlX7fq5VM0ZXFT-NAGv z@mlv_&FY8R`dS{pbNb?xhaN%U)^uzK@CJlqI!#Lq)bl##37CszC@Z1)J8R+`=Q?ZJ zIY!vrU#ed7Q*Zs`*Sg*rZ^$S`YMk9OC)s2lG&ng|k?Ch#ie2fhceR58JuKOXs%sFc z#O_8ejHZN(Cr&E)lQ0&!J5-IugL!4yUKEvHXpH>46M5qXjs2TpF)$`bTPGBJTfPKQNDSv2Z@O|QGkCQapx!db-K@$?$xV67W z8w>)1XCS_dbKH%#u-6c=jdY^8jL@coUv)ix@o9#4O{i9TXM4NgQK*PA#fe3Rr=!2v zfmU`5ejuyy6qF+wyD{3KwZW=#e*=m(vMK~I<;ATga#@;qfzQ6hXjvIobrBL|VIV1c ziWavC%R26|;H$2)2oM52;rzugfIJESY3Ejg`D(#oMaNAEfuiL96_o}vcfFj2VzJqv zZ3j{4nA8#NF#wZT^$}Y{ZbUrAft&_tt0|>ge?TCg0c?1U`UD!XT&pe#B0#Ps}ydM9fI-5Ip6c|f7Q7bl6TPFp_} zwPJQ$q>T{O@Qn!%M%p7&-F9hAxmWU?M-MUt%kXrmps|uPo70BcAa6cI+8$B>6 z-mX4-e=b{w;!_|Zg@{ivS;nUrC*xC$lJO~QGCoCqavr|Hy62i?F8AZ+?c}An(dX${ zt;@aJZ_Z->=oBis?EJZ6Vj?p`qU)c7{PYrWC#y)26~gO-;W1JqF!1|e=n29bokV+n zj7lH?-kQT$9i&vO|0?;@px}fk)xT2!2I~w+A>Svb3O@lNEq(7Nm>9tQg-B~XJVfz- zYQ6piKWc!O{Y*iSn;R}vZv(<3yZiD#or3QE4waz0*FK2uK7CQJziSgO>8ipMys}OV6c4_(Y{;JqEP$#&TN}5JVot;GOdA;U~Lah zuEbsLU66Pj`-Q%IA6h5ciaj2`Ib#OZFDcUlLtAiUlLH%mF|Oek!y0}us^K@vpl0>X zhx**JGwJ~M?#~7HDpPd%wpqz{{9ll7P(X+bZOj}0rQ?}L#3sjLUMh$eb_%)oqkoeG zNh6Nj6F_>1mTz;k2`$^aGr0115$eLq-^uCd=d(llh+L)H1e1R*(1?|~1J*k0fQYAh zK|Tj&fIBMLRLjXJjNh^32y(-(AU%W|0~z)fB-tB!7w)AV7EtU0dL57=RAdn|$SC-H zIO>kDGKwGzA@z>SziEaXLB zSa!?hYkjKl>MNXHNv^fL`V*!p+&65KzKiNF8%B>U?7zH10li})n}lX))6l%Opu+%h zw5G03fE(l27UBnJnF?;zR{rb1lfM1tE;+iG39dYSa&Mc6ng-$pv>v@i=o45}G>Gvg zhaAYbr5D^6u1_4A)+Pe1m;+LK>FP zdM*JzK@~Glsjr)e7vd~SvZkL5=pS>28VwGg)#Dgyid;h3eM@nrsdQ1KX#a7Hm#i z2n4;dwxEPIp6}!pioMf1_}Voh(Ea}pU!bSpenwuQ*z~P~uYV5zfA|t;svq;8?$@?hYu$iOFDV)E{&B>L||98&MVAHi+^sh9A zOaB>ax`r2yZrH*8T)Utzwl@81)%2fpe`s9=psC}fTpq%2GuOK!xAY&mEi?&b^Ze$~ zliFt9(Z=#0-NSvmfbn8FBJ32F2m0#^?LI_m5dC%JzpuX*vR%4q#O{fq{$lg|=9l1* z(3%w3RQhY-`VC?!Q|W|=Gihh6`^lfb*MWWY8Z<^sfrD!zrQ_R%90MqW*lw+@`nFvU z)mJvZDWMUFJW{;PCe54qS8J)AyW@d)VmMl766{T2>2Dv17%s38-ddVDF|IUo0*-gL zmuuVOc#L;u1%@Y!>Y2NzG;=%?maCr8soL8zaTO9(n`bV@FFPz;M-SBgCKHm7kOhyz z*)p=lG@PhPqx3^Kaa`%FY@XZ{-DLcP_M=s|yBr0xAlO|3JMgk`rsjA?3Xu_Eh?2H}XVmrEIrBDxhGzTDtEEp673n7wXC}9EyjgKXioiz~;-h@#7s0k7 z+XIDFJ&`X$AK}OG{5Xiy1H|dK#GGEn-T*px1Pk+(v8%oVyrQej>mec8O>?t+XGQ8e zEPoluHlqA$S$;BMtem-*btU6$vVLDZfcQ6~_Tg^x%=Mwl-X#`NdQL(mqz9PT`%L=! zkTtzVaSlWI@Py&vap~J10Q|>g`F-F`2b$PxPd_w$muSQm!^|!D8#0u|2;?+AOHc<~ zrurSg@pEf4^FPU5j)(6b{CAL4mI8>*aaM14gRejUVcrX2DLe_#RQiLm{&Gamh^YUN z-agik_Q@JWE73l2ke{fHt`YkmToY{aTcO%s7KZ{=-OtkxBl@e3ymKB%(T%N?mE*^Q zMnQ;XzxkrEL7|>$@tc3V8DZQ(eT)Y?euC;;ml8o}4ea-Y%9r$yvdgkVWvhPkVo~;Z z@JFpu>z%Jhlm}Krc{9Vx%krC5l!uM!T7?0`de@sm<)Qr(^X5)4IL^--^1>R<8?J?# z{tw*g69x)AJvb+_k?SGN7F1dgwc zp%ez!d6R|zcp%2&(?#lPgmU5Ed~5n<<*DNQa>ey0mgMc~{s_KVYl>iE_>d713=yLX z!bS63qYpGDyrh2xUc;d$72Cy1H-oy`k#zf=NmnJM(qX7*ic*^39+zNf2ZQ+2FA9;g8WlnQm%p1R>pL${-(@hX7ET$dHuqF`(@uh) z!<~@n8%OKJFN~U>9Ywe3;YLmb7+bPJNiYw&P$pSK(tuDB&bRA;BpB|JHek40 zi3`j^q?iAKbtI_>-bcp`M8_>eHr!&Il5sXb=AhwO*h_F+27g|hgJ!szfdfRx!+Qfx zuDRz3eSm3SzmI;~h=D8=rY8>tTJ<<@HS>kvHeyIVaQC|cEA-9sCp?g0_l^*<)A{Ae zYrS2{t==2rcqS-Z;h%&ATQucrga)YK@30CSaN7;4jIRerpRn}{9t8^QEBXIMdB|0Rt}zYUYUE||Hu$Y( zSBf{Ac#GkkN8P6&I9zEpPNWvm1u+hrCT~RY*h(b9E4f7Dp`-WFt!*!qv@gDcAG}Xv zJUI-HWsWWYZr*qJ>P+z!JpfC7jmJ-Q2MnCY@YVWV@fk-;5HDgP#^;2gls(qA8k|D9vFgr=OR9y(ya&ZcvGC+4d_$Q;U8P$ zK6Kn=b?-#2uFZ|1K4@YVtj`FX=E0oe5CsAj2r^k4A98o-ByDfbW0!$$d zqLUntEIkmgY%F}T`&Hmjb9~SKpZO@BQPbg>3w-$y4wS6J+(p8{9zj&k#AgZX#?T?1 z&KUT>cMGEqIrj^eAKcMeA!UcBO z%s0Y+JwE8a9tKnwBJFZfwQpPqnyx*>tI&QYi??kR@YuErcx+n*JhrU@9@|y{k8P`f z$F^0#W7}F6o~=Q6x&+}t2kil#KL)~kUIqyWn9Q4S#ru7DkCS^-;J?cFaGZUle2L>> zzhWZKqGdogD4JssXHWpKIQlw*9Yf~^UPfar{g1u0_VKrG*@K;V!!cV;FQC-=LxR_4=j)Z zzt@r@1P$>MNAslN!vGCzb7uc?5j}EC?la`k2#JHum!*Kx;OGl;um0ELxFi2=L@b8U zR|kj@F`-Ae7r1o>+%OTk0#9gF9=?KXUwTBDpSuhr;!^GFr!mHR>=2k4n2%6Eb6}F= z*2jU;g5%c9c75DZTq7}@SKWdieQ1U+B1u{ZP%)%xn{X~g6{RHHmVUpL&IF@pX@6gX zmw%Hl4{hPg0!m2jVZL`pykE@s_u{?pUX<`EGSkC)JYS5L@{NPLHW}6UY{+p7a&X?H zUCAt0;?1PMn-sp0b6Ad9Y5e3In>%+BRD^6cIw<$gLK5b^^_~CWJdho7ZkP`z4ya)~ zLQb5(i3OImCt@Sl{)|im4bk!`pw4yN(it`BMBIxm(Z{|O@k(n-_+d? zZV+EW`w@V^1uG{4kgEkCgtl$#&z6Xo1p9E*7(ztJwL*|I+6|H8W3$_@^Dvr!f1rBs z8F|QW9tse^t`zxT*GaftDsQu!)+583-5-9lk9F!!Hso5rd9}4^h=_$AgF?m;RxO80 zzxnDWR@*ZCd+in4z~_X4M58?|)}QK`e>cEykkRZtssE!v0nEz&ov1bqvtjyIbLq=7 zcPftdplpF?qPWQ`qVaaW;DFtog?g4D-PB)foC zG_Z+gUAM&tt7HO(G9m-Ph0(t<|qu|TXmnO7#IyU(%a3slLRp_u2j?ikXOtIC1UU>@$kdE z(!&wE;3Rwv(sA-l@nE%T85k-@R_)O*|K^}}a=rU}rp}rM%4fbe7>h1iNMzVMOMB7x z!lbtnGB(_QI~kw_B>cc1!V|5wVxQiKMPcYNREiB?sT|MAv_(77KkIDE=hp|QyhhDm zC57s%85uHL-QUAl_yCF3Y!%O2;y!D(*Ne|I+jLdCM|Q+)v(`6d*huNW%vqDobz8OC)vV^a5|qWVwQ*C?ttklPHo#k~X# zb!Imr97caW3_cB1jW(4g0g;hi&S~8cIZGjuu9$vh>4(vO4plw{V~du8^ShiMP*jQn z&y)xS1Up#v|6_S6#-H1*JU8WiWkGg(%r|ZCL-a^Tw^BD>_;sZoR=+z8JyW9MSJWs) z&!2v6NjCZUP3za?JS$NM-CuX&3`m{qUorq$yK6C!gGQpiV*sy zzZGR@DKoZ8@oSvWDDyEz@8+wf-l)BXv}Vvhb2WD}WvrARl*&iXu$0Pw;1LbA4mWwy zIw+JMOjWvNA6)!oZ{!`VF~hZrJTg`YEMrX@4^Qy=dEzfly{-~-3O4UW@(82jhOxSQ z_nObad-_Ne-V)F>6AI<&iIUG`(G2sqCzx*rE44xy&i;6K&y2BG_E~B=>btgzGkpfr z=SNw+KrN2{>U`Pd|F|IG#$lwj7~(6C)sQuu0C1xUvK*3CKmrl*OVq00fg;mn5w+-$V{vHyGFqe!#_7O^S1U+( zg43auI^%`msvE7;eucy$2peS-@lC-^fYpsdpC2b!^zEg0(_mrK~7Fxv6pIwOLO!RM2RI}DA zb#*=JYCY>M&HfK@Was+{25PgYMX9SPebNl#_B4`XcD1nH@_n`VlR%X1DuURd@eEXo z3Y$bxJTarmj%0!+H1rnlFm7Kbz`766>+)&A9M%&*)_IoU2)o@AsuOsP3=T!P>X_8g_R(y%j%h&y58af7kyT@Nh#OD4VJ~vN2FW9 z*h_d|QU3XAn&_2BX`*A9iV)pSIMFNQA&#DS6My(dZK1Ro ztOwjK%g9H6XeS{43l*M3`gxG%jt+&i>LUti+{b#0qU_Z%?zk_DTta0glL$}IhsMG` z^KBs|Y+t)ZzB8wnxa-^LWXhf)_U~73RO`8#0_Rv1Q1=_939Sv9J_*zmZs}*bu z?G#<+JclVixGTDH38dLCSk>7l;}57+ zQ^spaB7b<;?qP|7**{Z^&PA1Yz9u|-_E@=tMAk&LaD$Z^ZR~ytqk>KUQv9?*@zaKG z{KVBIzVEWEr$t_(sCnXpMYLM?5V1B$9ncH8r_h-xgpr0P^Nnx z4&R5)`=}k8PFWW^VmvOP7d&IUeeIDJVT4ASDna0{3e4wTYy_t@EtREt6 zWni8cJ>_N;G<;}=K!$A#k`;VeEzu$H(fb>|A{SBp=0)GWNlXmS12RqLqQ_jF5v%rf z`AQkHlr7P_WH(mqEUS&QUbb>LpQ4u;wKwc$ zvF}!XP#j{P%au{V>F6tuETd4>bM=bf>krfWWxqjEZibi07w04JmMG#B-`AbdO}{3Y zcvLM<0N<@8SHpQ%2OFUOa}2XM0`M{+ZFxnHw2tRz>ib_GOQumE@AnE z4b@@MjS^s4Xhb8AzWPu%3r#LGQv?!o&2fzT$&bx&``69q3zPf%9*GGV0`pOS>3)7T zpN^8&Ng8X8;xc2khA49yJ*6Re`bN5MTApfkRZwe}b2C||3VQszViOT;{%`q@)D=N8 zRCUY0jQkJYgLfEvQi49CL z4w7M&!;dVH2=Gxn`Fe=Z<*f`H8Z?Ak(ZW0BM+I`*Y5{a68*>Q^E;T6 zzUh9~nW}MK0|8Yx8b@Ru=ReN;7W86QfXH~vA|j=+z{-#Y)6UA^a-`mB09oqrBZ03@ zl~;yAb|i%<-X>W|PLteNFBEsBQdwMXsD!K9$yTx~$!S!!7ScLxX+=rJ|LKryF`)R) zv2)=kl7Y4OtGV!5k{wLGgLv9Ccd3+pP zV#3Iy*B8qtT>e8d@X<6gd|iQT-slvg4tzMxJl{Xds69fIo8U%u{j;6}uP>8J(_c-9 z-ROwpEL)3Hw4LDEWW>Nk1okDmpLeDUY8y3AgRETVCVS)Jr`r0rt1+v;!?glkKEw5- zLtQ-ms!`+O+qJvi;lm%-@;-U|^gp^RxZbsUb6L)2t}1u<&MnK?#8Cy8aZf{8ehZ+k z>U|FXYLy-P*yB%G``fEW9qsTvD5XB8`gdbiF3Ua5Oi}9NcbiR2uRybDj54`!*{6{= zCYR5A!DsmH*b5RHH~_m0e~FwJSL{ z$sM5ITm3hg=;#dJ?H36(;~~Yhrm-4XP#+a!$&3iz)NX@HNxAoR1YHyHXh|u)adw zkMeD(3r`CpFZgJY$_ZEe8_cGff;!2VKad|WU2-Wcn8*9pU&wn+LA8pSM3`Dvl^a@e zRgiOIU2mD4n%}`Z8SXoSnS>ek6jVl!b#V?4ukF(Uw-i)5{CVuyESSlRzra*0wQ)#I zCP1ETfsAxLqq1b9)jtbxD1A%8NQZxzWPwtBF%qBkU3GMY7xK6mp3{4sxxIP8TB!}y zT$5IpB7dqb-}#lTF3D>2)77X8s%q>Us;V)uI8lvpCH86mBUQ&``u|1Vy=*AslTMKN$}X^ zDQxbSl-+3)ygr4^ca&FSs}Cfx;i_0T-ML6()9ZAF&Dlw82Bcy0C5LJf*z`?dqjG^Z z>jo!j(_65SE+l8+7HEE=1LaBwm?b?ydeXFbIfehTMH89BX`RvESM|W|ne1@=tPXc^ zu7~C8*hVRzTt`Z-4_%8>d)2>=P4+KIUSi~t^mi($^R)H0%-!8G_dS-(YFr{K-%}v; zqZe#2&TcS9&9%}Y5-1Evz!^0+sc`2nl|-c8QkmCh+bd(#eAf>9)-@LTM$H8p_N)!I zavw>9{ZyDOQSOoytV6?oZ=((NCuy)x+hMOt!7B4u*!kfm8|R=8^5 zak`6EGTxt{!kt4m+w$CWV|O~7ZqM`OpC;)rLc^Z*sttB=8f@HREX4x%r(pN0aOa7y z+h8ZA!Tzruc2Wv2@6Alsw@ky1 ze#=(w<7u#8*javQ3bvPqJ@IWD>`&8RSKDE)OToUV`$q3~Y_Q)+gALeWhoxZe*09U% zusd()Uf(fx*w45MM0Hc6rb5Gx-eN2FsWjLl?XW9Su*YfGId<5a(qK1!Ve2Eeq+s7x z;m$wVVJ}F7z1I#~l7f9y!@gsOb)>;wZimfE!A{q(XSLhv`~3fOr_~8|*yb5YS`}&7 zpW9)BX|P-5ma2qIxGM$wu?lxK*kQeCu#eec$E9FB2yi{$@=~! z1-n$k_ITHZ^Yv-4YwWPIQn1%)*o*D3!_#2zu*05{f*qz|@3F&v&Jbvqb(h#-JFiUE z_cIhIX!X7w_L($T!w&mw3U-BtJ>xxFednjaia9VztLhZ&EgE)?9oCfw`zt$aX$rPP z!~WI|o1F&xV>@h43N}l_zGH`7$M9*V)k${P7p_Rss=2SCRln`F`i9eB-^DPRtnZ=} z>|Gl6QakMUG}u4bVarpn<23A@c35W`?6r2-0V&x28n)36`#Qs4RF%nn;;16KSxScGzVp*uQAl?|opS)s1PeO{!VWtk4K`$l9hrhXN5lTw4!f73&R$=+TqRlV{;A3OcCtB!`hIAKU6lrVv>i5* zf|aA@z!@kWhX|M0ucG&jG$@;#9MTz!3>O)(7A5DYZv(r}J z-=$!eXxMA)u+!6E|7nMvnS#Aa!~WV1Ta*TSn;rJd6l|e}eZvm>F~g+2z8BeHJ0>OT z`;qKSp}uijeVraDS-(jQGycDcY!~WV1Tbu^_3p?zn6zoV1 z+hT|PlA+FC-yhmxGg7eo+2%ogkNC(|-}*GzW9_hOf1IRMM8l4@!`7z3zPZCz-?|iR zjfTC|4(m>X{f!;glY%uh>`FUq-!#}O?XbBi*gjT$Kep9(6T_sPR;SxxU;a_DzOC7c zR-^5(i_>7^+&G(*b@!)W@6)h9x5G|MgZ*DS?4%SdC%C1JUb4duN`tMk!{(=82Wr@% zpV;cVm0{9e-*fG-?@dhBw_WzRgq;iRu#cy~e(|xbzDrZE;xqu*<#yPgropbZ!(Nwy zovC5Z+hN1`J87^1JM6F&?3o(&6FclqhDm#U$Jk*%t4!9nBU9mgXNRrar_x}Lw8O4Q z!Tv+TzGH{IDGhexM>bmBl7gM5Vb9oUEBAsl*n91;B`MfZ8unH@tRoHfayx8R3N}N- zzF~)bo?+5Xs}t<7&6g)>wN_SDb~!EWIS=VV{FD+ODpVSj6f^`^l-W``Y@ zg7s+Fqdv8jo0kTAwH>y93N}~6{>cve3d5wmzC-M=Z4;99eVG*&>ihlAY~?zgI`+Tli|!G-K_ zBa?9KS9Z48;l5;uv)A*7cDRfLoc~a!>0aEKZn-XtW$~_8XjzplG()2c-Ng|juuf_i z8eO73HtCN__3^s?n590p@*!6ea~Sgn`Emp;3-4S%7^x1U$s4=MelSzM`Zvg_wSf)B z@^w_ipUDO6SN6HnbZqLvwS#QntrWK#eaH1@3!PC|FRRpR2(0IS1OIuSnEizla!Uj$ zIMBw6t9mac{hEj$Phf>D@#ocd2j4guDcr<=Ty2ny6}2(@WWQQHTY%U`7k%SrEDVPG zj_nrsybkns3tXlH$8`&Qh`@vV{pF~>_m_fdNW!`QB6VDq=TmaJfaeqAcuK_MB|1P| zG{pB};bMsh=jw>s3<)5I4#|of+Ih02Qr5#UP+GGTs+os z$!)#0evKQ9KCL8gSeGfmkh6T#E@NslIakPi^AlQFGgkmOuJLb*ohn#MHe7ohYv4os ztl95c$%vKVEy88mtpvf683_v9HcB#_h-dOBPp7_VGG4y8~bB$M=i> zS2!gqsp(?fU9kNhJcrDB53?D5PK*^v1=+6^3x+J|^?uYdybcM@C69iG8( zJ8axftx`if{Nea+h%JC-?!m3%7!SWc!-ZlIy$PEn!`HE&$`hXG@D|6t#>H>bR5_eI z?ZMXgX8Cp-7q@tdU-t&z_84PuT#{?%NR$~a$u*6OH+TOfSlqp_#Gw~GdUSz4W+H{YFMyiC*i zJ|yO2VzzgSdBI#z-!3=?8ut5tPEwkERy}TBUal9%L=m&N(N|(#+dR+XcpZY@XvdFz z8pT<3M7IMMml0y;k=h?qn>fVh&ISKG3P(VZ_)pj>V+OY3%jw=2G?AD`b150sn9_}HuG`wYw;Jzn1ujPLW|S;h}RJwkU&yo(BPMZ2OjMSwt9nG z*x)Ifj^OaTGD^(zm+&*6ji?p)v|OvsGRT174IXd@n&!DVV_oN}zV*f|-`17+gmX5k z_VY}&NxY`0KsbhT^vX$@$OJQ3yF?yMYF8*^*GIN(yT{G2J{BEZ+)AvFC&dr$;w?r^ zEx}gdy4;v~{^I)y^ba*qaBF+>= z2;c-yW>rr~9*NJy@zZ|wD*hWi_fKNHZ(ho#+Eu`+`9U$J-A%my*cIzZ0+fBtfx%4^)3$&!xx+Xz4%dSV7Me)gO9io#~vwS>54{1fxaiG_etXI ztT4ilGeWnhjF7BsmQJJNWbAdeN?faqhwpjLpFeNz5rSL1AEg&Ypv4rlnu*!3_mtbY@l+5T5&;>Et0TG#O&| zUBrh9y^NPwA37Pn7o^R+;d@)*QWbZ$hG1x~{F4Y&^&%2gUaqN=-!r5I+zT2+7D_{G z=4g!XJ6`09*TJE*$V~Ivm*xpEZS>tW#;80qhl6MHJdkuJr*^WcW`R`pC4Cfz^bn~G zE}%R`4XLa+fnL}w{3skkHxPBhDC8Q$@;A^n7=kO%5+;a(hugu(JUOoRisWti?xNBU z^-}rA>XBUJE}e|(%Ems|KSj*Ai#7v2=mnb3-PEMjkHXE12xFKjTrcJRR_Gv%`ddW@ zLawQk&okgsrH-J6lz$-&{}w+}Uni~tzho28O0-5Ij=%Qtp)``HrIEZw+Ho(?YFTBq z>-@)h9a}7tg8zPRu*s;M2i56)?1cLU>IrJd1iX=nno_aNW0ZpZgm!Ac_f#|zMY!^< zL^cya;#r%-KN%`?L%oo+%_1p^T!nzHu~=UGO4R-hLV-o1jdbVn^@&{yT@FvvQ|n-* z6iwgiCJ%(R{C`>KtN2U^`lkeaZ&vg@547LQ6uQ36Pq`4ca63Pfw+V&c<7WZ`wlo0| zTOT3J-d*LXtd zinv8(REa{qC&P)VxyymCfQ}PLJnIdiGBcQl7NIiqFMoHJF&RgJ6;yx13n?o5od;96 zB7RhNnjflrY*PD2g^oy6&2K0+su=o71>8LWEvB#~*+1~8dTWC@xJ8K{lM|rXLcewi zZn1*jOa{M>FA}3h6n3Po$W*gF%6TIWPqIDpOM*F_Av!2s`GU@DaTcxje|kcFUc)h@ z^l5LX{G}Yhk0Hz`6fcUf;10YqDmh;c*(xCEgiVrUG?H8i0t8|gDLNSm3h}L6A(KG5 zyL6)odu0czySUZ=;BJLdj_(XJ)c-Y582!~Hkk>xtj&oW!#IrB75HIZeN|wmn9WO09 z6#f$>cQ~$GW~J?5_XgON6Hr0#nebK`{!mn>q`?~+q#jXHCT9|fI{Y$GhqJ6Y$hcnU z^e9ARP9@de@g{k!m(rwvHd7i@E3B6m6u~sKY0iaAkUP~_GET-488=X*=g)E&ckHLK zF$Y~(qK(_DX%dn27r0dsmv^b@m;g~mlS3NvYF)U>Z_ct{17ixakj$#<6NP+0T9|qrmU<+8%Z&`g9ik63_jc{Le`hPQl^oYr|hs^Re7MBH<=c@Wgc_MuIk& zVEq2@2*(j-Ay38AHA?CgeIpqngeLWER!_05I7JTde@X*N{w`yDGl&%;@TP2%ZB8TfQl+Gb{REdH?oGAw60Qfmb7K~GStRS)#`NdzS1d99ZJa@O&F6L+ykO8 zsi>n>)FR=%B?4@HzTnM9r1a_RtR_zVDdVuCpe_<75g-V|LRIVflhQ z2*|v+)xVP=7b$l|->15=6e(7zr;QY)#*}SRoef;Z4PJTvX5TGpsCpQ7WHz8!J+L>& zs1;{lg1K`dd0>Lr%XgRat-b0z`|y{Pql>?G%86+;CrC1kn!Tk_tagKFf}*x}FWX-u zfiKAqvma@Nyu}eI*T}0FDeggPf6rI!R-}?bWy1lEGX@D#-Hz$L3SB`c(0nOsdrljUob~t*{luW!Ep{c@5*q(oVvIQsnIpN4BexxL zamVI4M@X7+<3xJ2inoT7MO+lwDCj>!kQ;G=LN0a(NULf>$!Dd864&)5)mfNUoe?oY zt?KmV;1Bi+>f@67Q1B1TxdRop>U~aoJYU!$)xQO+*}6VD#dA6&zC2MEr_@D=Mf^BZ z*JBog-wm+-70z2IoId2NQO`oUtCJZ+(sU~%bCw2eQ9QRgyREi%xtbKTf zj8^n-KP3HOSk9Nz|1|WkzZpcV3gUA9D#r&I?fg8{q{U5Xaj2?T`j7iGRi9K(8&xTJ z4Jh@k(+T=6(g^^v#tnGB0s7>Xjg+n%v6Q9BZN49J5OW-ZZk_o|uulJZf)R7iR(Sdd z%n0-F{v!mcx>DpnmV}pVRxWKh!;!l3(cX&$G4W?&OChDbiG6GwurR_7)CH9tuj^=0 zIgM}BJ}3Gb3JE6bu@2OV1IpmEW1TG@Ii44xp!g}N^N3xz@@T~j-AWX8*$zs;x^TdE zlf|4vH_bT#OKU1o{7ifQqmf$X9FHPsO>0=NavNhJmf>i4gk0{9D6EHWntP8*{lMW< z$8M$-CAupj7IV>Q#WJU4Q_GxQ77|?YV}KTdOHEt47b!+lxlW_2*>}A1_aE5Tqbgsx zde9O^CnV7>(7`2+g#4V4*pDE#_{P{2rDU0ddBdSagsBwQDHs_3)P=mwSmX`eEu*z} z$lZ(N5qdy|Y!N1C4OeVHm|zmH4X@aRH+zRXkYgocu9{4QCgc>)?DGE&Z)kj-cgXna z!{;O!h4M(#**y(Y&exfDk`?T$e%{b(gEFzhlLw4inJVmV=wXe_{u$=Ao91~#rAHTf zLw6m$1af$Xlpd|RWV1%FaL|2wR3{PJf}Q`tbjV#ti?vX&5G_mUd~hle%W^W5lTQ5$? zyjT`Rn`Ieqh*;vd(17?tsStg|(r?EODeciYHZ!Y-m<3pc@_!<7hN>Dm5g?+B*+fzgKHPq8a9Lu7qj`B8plO$$em0g(b0DxPDTuuTiRFbwe5JLIr}%xNW`P|ajmrm# z6^8E~a)lvUI^Mp#AQ@Z52)g~dEi#lc=qsz{gm9j>&pL z#APf;boTU}%Jpp<#NyBsUbXdyT^EzT+yo{|82D9EE`@iQ#u;15Y06r`JF`j_(z0iN zDrm>2$SkkfTqzb^jW%y$O{|R%-7p6ORNnMqiB8h*4XcxE zr+*Ri4c6Q+;&PsgT_I(Klpx%*+v3{fcBy6bmfCTemAK2Uo7AQfZdN8umVeR8jAgnx29jrqb$ zjl&MM5=>-;x$-TcU^N}^KeW3(%YQZ* z_mPsyr6g@Su%LmO$W*hEfypV)O+X8WHtcT5vVL}$K+>Xb^*OU!M^ml4FC5b+=v4^q|Yfm@HmFwP#pEJt z;K_!F1NhwRJ{?FOEcFrTu^B$L;4~aiGD{j@#B!C7npon%eSmutm;A7>gAz~V11b_% z9m4X$=2*b^^)w5`=tyEHy<7~Xs)JytsbEXU1UibcY}V6j3Q18RtKkf3<*3XP8gvE_ zR#{GD5mof+iZ;^JlZNyYHcOh)<^iPHP39ev2Fa1MNbe#IOZHeiP(O{@E9ofcEEbK7 znunS53t?&zJdATx+NjBwU8yS>-sR6QPf>)Tii4ITsg>`>z8>BLE33MT1c?Gw-%?fI zkHC(-8z7F2Sq#lnzE6|G@ZAsld>kETOWOD_e=S+;dvQ1jJA@J!&6{ z&i#O|!2EpWjAd*h%BpggbAUwaY7(U6K8;LT^&`@6=NomPJ)q06d&a z=&UmJkPS;inUG!{nKK;fmX#2h9RPrjzMDT%LYD=wDJ{_hCnrlHlk8s*C&PKmobh64 zqo+>kdx?UZLi*h4lEL*Q!p{}(9_5rG?R*tNw)+IsWt59xR}T3kf6LhN?%eV5TS!lS z0&XNmz%3F>{$$X^I&$XinofkLOHDGLDc+T)r)jDcv{=^3e~+48oYt^Ygdu}d1zRwcQ2mq$)}88m zsIj20oD#O>Phz<=i*YMusD_s-*qk?k*;?T)Egx&!IfV-@*?)+sVMVE0NYII77Yp*E zHtQ&TCR&F2VC~)67ahq*Jl__3x&#=ufMW7RbJ`&DJxYTFIV^YC1e#;AH$;Vv<)d0#{O~E30Gm4%qSfXG4QLu~`TrmlU+Z~QgdN;{T zc##>pO`2LKmMgTEkQgS>`SGHQ;DsXjRmB&A3-2JtuR^jAjA13IhMkqx9QuBnuc)uo z<)8{n;MdpqwnUAk)8L@WN6iwRr&>V_|7TnOy>+dj6i6OS`tPZ5DGxsXp86Ig>U&TN z-%LGK&DE^S!)cO#Uke|~U;MwhzSgLHAN5MKZ=Gu2E4IjRc7vu1o!aUPFsao&cuRH} zVQ3gjI9eXMdkIg=7fdaW!*F$Xd&z&lz3-&nsrH`q&cCz0587Y8RFuoaeN1%JRk;K} zyrQQvtd}R`c#niU-fq1-(rUddj#w|@W!6jW5_ut;Mf&+vHBW3Qa#TmuR-6r&$k51z zW>Rplww>Y26ReH!aEE3w=szXPj_#1FkVtW2#%@(4EhmX3R)4bWb&G zgOwp5?1KmVQn^#eha?M!Ww&W`+mqr@T^LeW^vwpZzCj$6;3Jsxf3-PR7bmPPTO_2X^9f8>lKHdf|Yqj899ATdy28!(-gq z&J0a37#luEc^_v2&*uE+MTfpnP3C3$uQjsmicY3b+{+HrD9@CKxK=&|{E}(T6BITb zCyOQ60{#TJ_RKt3BW%>nCQc(A4BK;JH%~y}f`gnF0Mk_ng;g;+CfisjD}T^QQBAI< zS;n|G3PK#LDv_%i-*jZt$O%$S7DVh{Pu*JT2$& z0<^PgzpeKBDN#N#^ERRw81vZJw;gP`2m06}yRv*tE9&rb^nBhwXfC(Y&$PRY` z=MEh!YXrf?xl#j6eegt-a5mY8Y9&EnWDleEVhY1T7LkHN<8ei_xRO^-=qDw7q6#mb zras+h?CzQ5JWGA5mgN+TXE;v0xwSStQi6xKn7vwz#kD0A=(~nmXAmlX^!6M{5PV#E zj(B?x)(#+1S zkS6*IK!^e<%fyiyHwjpXmU01Es8(>7aHc3&m|zTD)v#1Cy5~J2lTt$R=1b?K@Zn34 z2XjG^Bp z(v}*&rSGf1QIMK?(c*Ji_mifZ-UsdM!Q)NQqh|oA>CRmMEcrq*v z#cK(#k~goFyb9Bqp%S>XR+`Xa32b|U2FuRg0 zjGR{^a*c0yP?xs}*(RPwLink88>w2d)=$d|Ct7%6k4ex;tZ zB4fqW;CV~t>v`(dfYM;C2eh@p?2L#)NpKI;caV3dg2>HoLF01$LuT zBHf*-XXM@nZtou>q+S^R~u zFbEgL#GjLj->vBA@SUlanpcekEv#l+G%Ci>y(vsw%X{DoRo!L;7NU`Gi?vXUAs3XY zM{&Q|&_;wGCpyBK?Nu+~I=s?QSQsjLVWX@a6?QV3#U6ut?0*TYlGe>MYNv|=7pjf& zMTJUobe;dFm--y^#OuPIliC>OC(6F|p>4)5BQ=VCmIMbVEJQVG9s~x)T){_zdzLw3 zKOrVceM-5 znm9XBYBfvK{#X_w&{6G=!?dw#9LUUJThGLBhb}YwO(dS!Ah3wlORd(#j}f4FF9_76 z0xU2g1g%)~L_78Ii=8ifkB04(f>{wQ&;C6D($Z1*Y~l_OS)$S0)@(EVbt30!0bHz9d^275A5pCTQS>WnHZzPYX(DJ&WvAwA*= z>BR9fs9>#nJ3-=fpi=_dRjn&kq(YS5G>`nv^TWD_ohgo>bf+;xTuQF@(LO!^IA4F! zANNJKu2v(Nzjy2`5{uxH=DlB#MRUZwLw@2sb>CCqT`EWscg zuI4efzce~w^K@7pk2%<+E5U&gw+oGN)iPD>RZDpIj|NU&%&_x>2-AX3-cmOOtM->Q zfl1E`u~f^0YGo7(EtTZgZk$Jjg{jP7{~S~ip#qvm4&zaI4ocYKagvPp0kXX_s6RuC z70C(B$F*-xA@r%$^vNsD83u;8L zRXMXt>wGyknq6$#)m2Iijaue1Dm?nC8WjpZ11eVuV0lqZDuO7`CcctFY6_)<$5kRc z9ETMiJsOzrC3<6%hP8U*(cHt@tv7D2UL^v@VaW!gb}`Hn6Wf;(b4nOWJ8zPo7AqBd zylPKi!AJ(MuGlepN!#9|G2stw5s1yQ8kOwI&fHg2S4PaTP6~gemF_CVnV;3eA^Ikf8gZs68jhx+mt&_I_Ckay0`PR6XRPaSO~q@3jyeR2Z|<_oj! zX`6K%jx|!7`NXF znW&2JKI^uH_0cPOgCFOg_|K-ziI`4vN34Ary8`2>R7}V+X9ls7|0WPxA_#^1E?R4E z7SRu4eOZ;4^cj?eyefUI;tzkUI-Cn zd1`3n@*x>B1()rd+Y$cQYfWrLWB8JvyirPJ*h0=zS?(-+SMdA*0Sav=zE2rBsx|ZA zSE{#3e%@uzR5`OV`2}J?vVT zaYiR(+6%ABj?&7VLc9Y|Q4W>R3b})Ryg@bgW|8aCrP;ti#mkjXj|hp_N-(s34!3C5 zRzn2zDN)DbN)MZJ9HF9u5h@B9ypG_`bC_MwCtVM7HavxA0j-WjXKenQ1;fMWT2j|T z>sp@REr(OBd@0pR^ej`_v^0nHYE&!Huck>rLbYlv&ABlvrCN2jq+r8Qh$$0wi6i|GJ=}HBY2ysvb~t0P+MT-ZU%K@gJN?tcxgVLSUWB@E#@T% z1@hubhU5%j)neQSjVWu7h^sE=Q_m9;P@F1=wNkdM+C67WDC&Ke^DdpRjRY#i!?qMN zRf=mQ1-i}6UC!HVktI^WLbZUvYWX5crTeJ$1?fi1^tW1p;9D(8@KkFQJk_EFPqm7{ zQ!Q(-#3{=i_9aeM%j0Wh!R6mv&KfhE{oB96uR&$Cvn+N(6JB$vshEc8FG#Tge0jos zYb148?Ful%i=}NnVFeql93gIovfotMy^i1*21InS^zc3kHoGkuHBzEROVm!4L-s4l zC=oZ&(Y8`wp!Q7M<-G3Hgf7W4_SxG~StEGUg5+$lVXuX^) zKt(l5lwU_}k|-_bqJNd}$vQlk1X)Hv^j?V?&0A7>JusAzVLI_g$;6^LM`uW4r;aL0 zMlBMoE|I8x&=st9KdI}+{z-`%CQ&kYCOb&&IPyq#oLZBwq{;GlDgH^FTKCf0nL4%Z zT(#4>!B*N~-}w$33Fd*p3(FJ+!(WR2eK&WQeuZ)J@TsL# z*6-OVQ1rWkMC`+QZvtWW6)dqJ?ozuG1gNGUB4M&~p;f>9qoAFaglrI@@W+Of^blFx z<(yluCMY^7gNFr7N44_fEm=lkvfRQXE`>?_So2j2!5Vwke8~Z6UCZO{p?ZK6(I@r>1zcu!sb6Gbq=?G>OUcWL#t#QGDDB3!6`w(6VTOU?CNs%7qTUqyYrWG>$k657rD#$={3W#yYwgPaa2K@BTm85CpQYQ& z_#LC_i9V;5`lp`}omqFJ#OR$Iypv-$FM8^1K@OlBkD_PTHr%`m}UMVX|GFNiFr2Eio4l zSfLBJmF1Ts&=(g0Bl_Z$$!kKHT@hc8V5Tc{1TUE`;)9c;o}j-Yfp6~wz7Z)YIKUH} zMK%i8p5Pk0Ob8$LMx*tp@zwLA5dJku87dPT+u1VS!7;^F(4KjzR839!`>yqJ<#f33 zQ*L>bwXQ4#Bzjp9D*d%erB|g#e=(SfO5W3xdDk2|?>@G?XX?BscFX(ILh`n@I7>@J z&n9o<$mpMcoRv|hs9OJfnX+i~p1)tA_jBgzpch>^L?D)t1=Z?^{Z(%5l9zqF9CCl9 zV$#4zm@Cw@gF3WE7yMCQ>^jUMy@LSBvK)w6g6I@!k{BRc0^joOQN@f3e*)ZWZX z>|uTqJUZjIR-dYuY!2#E7jhKW3|d=~HJc`iedkD+CM0p=+2mYwkZFK?igH zHL7E4_G%of*z4(w?c6{9H0IsE0NB<6etx3W0j^4Bzx2@AA8E_}YMp&$x9oSFO7^;W zoxk7m8T*>{Mo+vaGsD$5W`M&Rx;oc;{^@?!(Vaz?gB6j3f9YT&-I$z9gXgrnyViPx zSLZ@~-|5vCOct#xq4Pz%G`c)H@U;@xh5EeE#xCuT$A|!hFMM^sKJ*Ujx1RDy{gv3#Ul#san z@`R9h__rw`vE)A0ia%v)n-UTsvQV;Nm0WcvLSld7Nde665z`M9g8v?|qM0vgXj^_vVYfUeA!CLzWZNFAB_(8cF$k+7}w>l2`+Uqr$TOgXG3T?Ybc?g)BfejJ8a{d|IY@CrQJ| zJa`9zSM@n^h7o5&#clGULOe%u`d0<<##YOGIg#vdUFz_$FKkBym@I+G{;|sKwyYy@ z&zk32A*z@$;9t>@QvuCD4X@ee-`(xNz+Rm3%6=sl4`p=)& z=lmPr_xyB^AnSE}Vg?VGd)_g_Szk)4nf34YG>aNd$2+n0YBU@}hV6}6=XW)hX6AOe zR_G0`o0zLa+uzU3h(D;=1Q*C3bh?W;zP~?*hG9XM#W~NAn^5v^qLJ5*{PAR_36ort zWt?6k3^@TN%$IxG1N-u3XPKcnon6iqe-?43Ee8PQPhpv<=1+3F!wWCSeHhWJAI(%B zd?)39wV<8SMLX5}EySAljk#uVhwmy+=*ApZW7hZBmDWhWoD0q1#xAEfQIxEp4PwQR zE!bVolPFB-m5)lM!Ck19@qR>hIlufPn}DB#r6kxBl-t>>`LHx_aiZT9xoIGN+@rFT z>~C)@+XG8x-~JGr-9GP+#JQS+{5{R!dOA;k9vlT=_Tybj;uY3{jdvN^M+7lLAiK>P zqHWHsjKKT|n^rv`f3qi8WmP1&FLsl#TF~Fdv@U9a?~4ABf{Y_&-erco&1R^gH8zWM zhmbG%)_kJNSt?Z)n&X+Ot;^}8p3DZ@rKZ$@PQNzas^nMAPZIf6&)<{bKZR4J?HRs- zC@ml|E2qo3MRH9V&7Nd6agi-!X1eA4fP)K`Fo5Cxh<2|9-IpQ_1Uum!dFCchD z&#F3Uco#$+>j~XFV0f3>N-hU-YVRVG*w6Tp*5%}kCw$3BNr{LmW!8{+6OMdtYUh84 zJF{3$%+TMsVWrvso+yZC%C>TmFPv|wZ_iTBQSAED*vwHJkdL6rM*N>j(Uj>MWd^#8 zxp>R@S&nGF{yth9;RbbM+=JeV4{_r%q*g>4xVG!2{_NKI^8>KRQAcNS;-HJLc^~;s za0g%38xmeyY^ex;pc%9br6L}V_SZpAs>1;e3=jkCRhf4tRPXu z6P({{f;Ih=()-9;4Ac1#VrHOkUz@$_#fwsA zr>L2(g^A)8*++1(0Jal)dAsH(5io^j___iHnBolINeBcuvBH)= zELb>YGBPILDuyp5n>0UKQ)yV6JUqch(2yNk_Feh1P?@7 zXS6*xE29)VShNmZl$%OFyVVukf?PDuzyK*1-q)(x0lroBvpBaLz4E04|Y|*OVah#^&j@DZs`z=%IYRb zY!>5w$C18$4?re>%iK+6m3sBz$X8Cw4dzVj{UCS23Ppf)x%#3>^Ed<9}(DkVQH`W>U|x?!iB>8 z;i*~1^2;*;=;^9|zhC{Obk{s@~8cWGb!m9fVVJWTdThwoNKY-7wOf?DZF z1l{>}m3T~xq-q74y}>^+tt86C79RT@zE%7(NgE!2jE?uUR&s*!NFC{G#`G}$h(rwX zcx+}OCWjb@#1QBxEB3YKNQ^7gzqzy+?-QFcco?<97UqJzj7)6Y!+J_d&ddIqolzR{ zwJ-zOxnO5T8`7^n1D|LM_GWbEW<2v#@F_Je+PS(X1XP2*F?z2N5PLg>fjkzkX@bFf z9H_;}>RKgo6p{&F84G;`#jXIxs6C2eJ;Ccc84BCRhDYV&j2jQKNqfmMhid^Uavmu^ zl^m7_%;NcNM$LGN=Oo+>U1hZ&&+Bb=ZH!;3^b1(=K=t0p{F7n4FRS;hzU^dTRs3Kz z?T1}qL%4JsT-p$)=QkY)Y&|K?EUW3uz&4|Pn=o*%O|Yq}di#ZC#`2F{ygiQaa(C9@jf`-UuL1QnC;ts6s;qyt4sG2FwqI$#p zD`S6z=B{T+kmwJy^Igx{+I4nbqOfOmVQ--zr3B8)#vM7NCQY>z9XLsfrr*ZySPHYTtVB!hY797rnJ{OSMWt9(9Al{nkEY9DGI6evdG%|0Y`V^wMB75YfCd zfY>zNIW?9^ApY)1ulYsJEl5MXd9QA$cSQuH^UK>xE@|HEZ|4_JXnN=D6XGXZ?LNmT zSuMIxvXUrYFFIB{OI~SkhKNpv2r)X$aq%5(%*xp)$8pfSn>dQis1@82yPo~ZN}&zz z^9G~Q%pAsOHS_Rx)VztYpXk_Q{J2&Ki&!b6Z}pU9-j4VmlCbU3CKV{N7H>y_Jr-_7 zMjUQnI?6sU#l43aNRB>eAb|i0HS4eghu5*+6P(_m>Ci3)3ZVmTip!$GeQ0JS)f=l( z%If;(l+@iKBv}hdrgvb^u?B~7q@}&m+oB&FVO4r1xsWrH#GuPk`emv&Piy>YmOfeb zUj8zi(D_5&HknW|5Ap`LM*j+-;-w5SgL)s4@r=mrmGTocdJ}%8H3fJhIzY-}y)Hc$ zZ?N4R+!UQjE(XRPnUC4#2T7$4_5GSzdNOHE4}SR&873!^GauB`gTiiTYlS*ZKV-PN zjxBC!`r&WxLO;AGTk`ZG%YQBW5qu59NpKBE@}re?mU4I;$|1))Q5ltbc$@TW)Wgo- zY4xyGKb>tny}{@8{4%%>OU^*UQV%abyn47J{^@A+!yI?mo$C%f&s_CO^h0m$$G(|k z!~Jo)+K}sUY!qEFKFBg}#V@58EClgB5`*&qEJwk0?%)QGBWCS?V6-u~;AY;Wvheud zxT^PwKG@)jY|Abi)>2y6&y6ue^uY#q*NgtmTtydEcI2Q}`W^piZ8$@C%?vW<0eRFokB6LGg*j85a-n{;l6u&KP{3#7?MIOqSsMI$O ze{PxM;`3^yz_|kNU0Bv@tp9!Vuv63tGuAc8h`D*fip)u1f8 zkfiFAT*Kq1CX!T3^6=PUiTH@54UZq7mCz&@oFxSFK0s+L~0lGIog4m9wME2;2=Kt_AOP zbzR7Lztq=(^7M>&HkG(Ks;b1i+r4<-El2AH- zd$&ALC+x7IPNc!5A-chiXf%Q74?z?s>dg4@J{`T*A+lFV-A?H!!dJCRW!+!Ni_+k> z#MGx_GwO)HYeI$<02| z^&BM6(8~SnTub3(=c6b~?hXT_S4WPO6lu`QN)31Z?e= ztoeZda?O2WmNlPt>G`ytP8+xXL`7-q5?W6Q(`8laEW*9P zcM_$xM@y2WerhjuBAVzSOReh8XqO$b{IBjB!Ce@;=p8Nej{a%ALn~sP?%*DG{f^T$ z$N37hoExH9&gayh!(^e%W#c#*PPU=Dxr<+&+aD7#7ojo}p$M8rfR)SXg&7J z?u;e|W!XdNu8$7!0F*GfVZ`IMh|@4;`H$B6U#XZyB*XK_P)iXFcP6VE?)um;|8YnW zOc~mMBFTG5@*_!}pp{ww$ytdON!mLkWu~OuD#9R~JthNfk895}R?1IwFhapNGO6vI zv7gnM#QNXi^to~9b5XV=WA0^HrSWdNK6n43|D!&4ZUB9*;S1;}#w~BF@5H1och{|i z%9bGyB7)0uQRm`E=t(46OdFbWJKABCp1`_ChhuDb;26wsIPkFaDc9~shp)FQ_<8&o zt*^>`w{MB4H=h2ks2xmX7HrMvWNb2?exC6XgN6T^3%Z!88F!o^qDBBtlXg^>#rupH=-z>#pCg4Is)72J$^vPc8))_NflG)PHyk#un1$R2?S_RI$IV>`Ogk zk5?wzFTqC5u-`y8$|YmE{}|0iW&7(YzN{-d;93QO8S#P?55-QdUe{6Q-Cs(MbrlEd z%9t98_?@wcj@*U_au+{8*N^Hjifx8mx*3f=h_f_pSB#3cl*LFEp7Fl?FpY_4OWQ{5?A$$Eagsbzo;>5)W>7$t{dgJsIGJrS1mMUGDt*Y z7m_Q}JX%>z5!?5&UQBXNh`1Y&Dy|h$5i3QsUPaH3#vfogq|M0nG9N5!#Vv2H__j#b zfs^b%1h7M{cXXT22>-Wf{%?o>f8LG%!(YY!-PR)#7VZlc!11k})JH8)aFt2$Rk3XE ziazu+#qhqNrc7pb#k2pZSSP5KD+<<0A32xhiX1aIH`lgcVO3{WbP}0bYZH1sg!MVu zF)Moqm`ic~vPqecH@L%<4sA>?)I}OKy(o&cQ)X&ns7CquM{_0@pN)a>+qprb)Bgim zs>a~IGQ*+&+$*ZBKdZlaLw$O3k!8;uE{*pVt@rj?&y3Mqw8`6xCHL3edp3D%>-}e$ ztia*uZqBCo=`8Ho?cuKKQAUREYIRv(vwx?QCkex9`M$w-lH1WF5J~%C!SBjWv3cRbM(oI~WTxts%K6EG{@vM<&(b@G}d zgY@2Gqo|sn+Ud#dKkN>e3gw_I6Hv2jjatL_@qAkNlikoyZA#XZe(okqnw_6c>9I}3 z$SUM%iXh$VXVknad@dIlZuEZ~lX2hPDxSl)3isLkTiWB^Z`&UGw|v|7U|r~>TcGKA zG)J;+{^xUB+a&p2Z)%^Q4y#=)H?8yCEx8}sqrxAJ6&=<0ga^|S-CWo;gU zL~Z^s>vD6nr5Lcty>ODOA@fX{Z5Vy>QjxA&}Xm(>#%Qm{yFaP?|b zhSt0c|1h+~cB=xl7a~$)vbR=E$J;SVHoMqK(ML_3+xzrla?Tpa^T^v)3~Mfo8jg(U z`QUFX_rFbP^WO?~looc5a`=lFA|z*J_efae0-r;2wLisHUrb7#;D_F#sIl<(QZko! z9&Z-EQDq>z@!6HT`nDruVIquL@pdTJo1Ne;eyd7M92sI)mUvlpF>0^o*ByM@%&d?x zhvF+yR=k~Eo27lrh%Bi-uwZr{jA1Icq9g7E=SDAsTev@}K!Xlf$&Vqq)K6xt%G%7- zQkfxzZnBh{v{ZBKfnoz~42D#Mn<3IFz3@%6WIL#_aGmJWdXl4{*`SpX`v-yWlDD|s zxZ|H>$v}asmBGMJ+*XxOO^;a3jbpc+q&olbH!*Ubk4dlgJ%7L9Z^j41m`1G_T!cWM z2!Z+la@y)!Z}J4+jrZ`+=1@Q*iUd2A<2#r zY~s30B;w3&rXwy!=n#0mUU*#Xe!%H z1FOkSf3)PiV~)re!yYi!0KI#4a4sf)BN4XGnitu_zR+Pg_AnMxWj!*>9`NmO6L;%y_!uz>MJd9Htm3PkG8crquC&VutYHo(TOc!*`Yh zogNd#QQl^&d(Py|7Zdf@wV<8u%YzC#SRL6&m?p*pM~j)>Cyg6C_iqc9Rmaw*Xwn?m zIj}wkOoPpX=+?`^10OPRJVlX6c=m)%Zsh_pjvb%Vb zamNx~yv5DN9Wox!`SEewKnj;}@0zll&Hi3=XGg?qcmVY-0ix=7FZY)9T|{`V)wkui zyVm(XD;vI!7(eO^jn|QxAq>|`HWwUK8Z5g`)i)-#@>01s>Lg+(^Wil*cIvdf84zbd{ou7_@7B85HxraNE8({Xw;04nkXu{B4%I) z&cKYKf{zN8U#qFKJ}8+1tRM-KAm5i`!-jSt*;`lRJ6Coghr2uyuOMiW}pfhV#H1Hr-gATN zFJB~pAho}6THF?}acQN_LEM*+oWD@$SD=i|V>x?bF`hHINXc!a597H?rANtwPGRwW z_B$5P_pY$plV0t=UsW!>lJ=xm>!cFZE7e{X_f*>7nbDrww49TN(t3y9SSr< ztHiUgK4J;>{?%94RY$hz10oYUDU-?`EJ>E}`nb;(GowXiEYb4T-F?R(ySmeQ8dwAs zQ08VSLST1OuP-IiIl6--75(cMA5Cklcen?iVMf>-M2qu}FkRI;-94VAFnF58{(Q4U z=!!BILwKVf6SyX1z#u*T@zTV2pS3YEvOrYh_$$egxN_?>8Y;PUuPoxhA3g7p$KL}!H>lB*TlBNvuXP-+#H-d_s8#E>C&8(wrkmlD6?VO61wGn%`ostv-NH5 z)>^7XPT^!R$R4?1LhdI%iJKliVxSzG`F?Uls$3Ruqg=23B4heb%KMKN&!k7kOw_{l?(Ukw*)x z6LovDvPvW*a3PwM%c;&|c)A+*7yBLi54YeXmlagZTU+oPckMsLjAvt*qC0~9D>k}o zzwh9=39bkJEIG&*4LPXMz4}Tj(M3aV$?38Y?=)7NE`(U$EAm$!su>|Ki6?12)ERnA ztgu|2R##*EJZx=2?$yCGGsWtII3>`*ijdXKZ!EEgf@Y*rdwU7ama84ooqSFo#`qA>Bdp21jRT`zdPQtF{gJ5!2zf5f%If$<|8k&!Hqsw~KZOWJ z9@PzqO58eXH!vyP0K8b1+jYp@y(Uv0Q0?x1H&{fGRn(T%eP;=CGnrV+y;MU#uz&9C zzLP0yH2hJG#LJrp3MzIcp5u|#x(^5)+Yqn&xV!rT#78a}s94V-{y@G~-Mub2z;7k2 zqrwDB?Vm1pBp(969m;5yR~j0ev$idcZIc2=$WMBz^%VC!EHat;=fpj(V{XS`uj_F2 z8!-A&=k?Wy2r?V#UPCi`liAk$)s^qVEm+%5hGdVGX^(j|n_1io=1x-4YvxW+M}N%q ztD`^Wg2?!4%r3=6WA4yWf7IO<%V}&dVy3IrbTwJ+m@a{&KG#}t7->t0RhTb))LC;n zPp~cOjCC(Iq2WCojQ1H^Uhxbm{-8lA%Fi34uAebuvql=P4DAPv*~?9^Up9C9asAj` zZA|&_pfT;k&DHv6CpMLvIe_dw>?o&v%A1dRy?GnT;tW)-3oRoXZYI+jsbzB`2c$mU zm@KR~1Xpu*tjc(Hjn8qw*WDbb+!6XjNr4y1C$>1iZ&z2vA!I*!Dpwd|)5n+=?ph2g zV4a>tv%Up^W3bvz%a$rZ_=YgDF@WHHReoVDcZ`lY|I96` zLQCDp9evp;{45Zi)`3a+lo~+kw>Bl(n7cNA>ucPfixEKprAYW=R+eLCZ$4~>_t#|{&Ct4X=R5@zW4o(LuRGddM%t5mIVPER@Sqnm^MlAcr}K7%(ztyxJ(UfhofLh7D2o?Il=`SWU308$;)d zb@y2q*|XS*| z$XgzrM)+U3Lv=!^h^zzXW*Me}xFVB^Q;=U$` zJuxq*#e~?)&m-sMt2aBFc}vbI9O*c%P2S4SP6C2vAWu>qOV7i*12E_Fu0q->ARJt}Z`S-5LM&>I#hd4!*RE zkqN5@JK(=wU6E0*EPJS~C{-QanEzGS4GNByo()P>ba)qC<8Hp*v!mZ;Cbhh{A| zrnL%PX%)Kiu7pt;APe;HzQW)799wX_OD2q0+(j65)LbRZtlNrtVOu-XZ0n%!WU#Gs zI$>Fz-5nh8lDx(ghaMlUpgv}(R#piQrIa_ex8h`9_b#vXR6=D#D0;90E_u{us=~;6mz+MnIm~KtR0?(+u$qho#t>)&+zI=Y+T?&8TDkF}@TQ@O|ZQS9WN^>BCWpS3QjOl?rZM@nj( z%)q|O9N{B#YfdkfJ*ZeoPHxsh5`xkT`dF<7*Fg^!I7{rfTp-iFcKK6|Cb+Z>~fH}bQ+4Q;df`>l;0SKFiI2|p!$bw^#& zO8v|HicBe+K{RC}44sR;be^~02J#vUhLClrgDy$t&Lh%VnfX^cg?)EMvQ za_TWws4=S87IjD4DQ!F<#@>u^k&F*PX6E>Q#`>BX)?J}xDDv%*ZC34^a%9sf!^rlk zk^PAlzItS1$f`yrnHkyjJX@_7cd+}ovEx^aZwX}bVKqOXnJ4=H^!v^%-k*^7=OckI zmc+0y=sv(Vc$gLXn{X4;V&ln~TT4#3DT+T*T(9mwW2#m6U!d&K8KbZv7?l($QkfyOtXh z+X2_R#yq#=fWv4^+-NlTd2p{1+v|Yw`c_}~;7tgGQB(Rhee8*ReUO+sZQ4b|U31eX zqGql3Sd-bGBd8S*Be(}@Z80}t9Dh{h@Mh*{G8!h61F3Ae*R^TIQizDhwe9Jsvlidq z+-0{DP2*{EYV5~tfyY2@`g4=n*mW6<)3@5);iI=2;XhN_*SOnhMz6i>yGWNJgRe%H z<~#f`?B-1@hW@Mbsyz~|Kt!(IPJa~Zl~9(q&EM$mZcHo& zUgWGAX=K%mJ0Zu(UtOSXUIwV$dI+` zFS#{CxT!@HsB=o~?v?Uv=j@xjKyt4A=?yxAlyR?=XFEebkxW_MG8V`Z+?{F5aKD9c=^;!-kc1B{5YK%pRG^%mhY+q{r#5X`{SK0})cz73BZEJDz*+9L zwg#*dzDQH7`QBZkPt;>a#tEETGhGGcvivynHh2>p0@7uk%%I#L~D0rK%DB7IctjS|~T> zO%D0u`A#@;&UAdI1czPpWGF2d=YT%30Uv? z!p+$k`>_Bnp}xxP)B7QnGv*ai>xov3nl9`1xi;yRw;SP3Mqs+Ozy-l-SJih1+Y+*c z*GNN;3Gfjrp(oqKeo(}nJ@R0@3<{0<0;n9)oq*%RanYh*@*x64=wS?i)tc9Iy@qT)T|<|4JW7GUh$a zO>NUv0nQ?GeU9CO-_`AReXRH;niw9a?3~{3mT1vMfyxcjA4Fn%Ij=dDlbR|!YyQ`X zwLmIhz3D|B+Mc|P&53{*6A8bob*7QT?O*mQ>T$*ZMjz^r%7@q-)4z%KtY$K@(Cq1V zB7iGEVtX9oJ98`#Req}=IgyEi!2q&zzpH7+02&DO4>($UuJzL|@mC(L8G=J@1AS)& z%eVZLEHE2I018z2hy;=7FEyDWD#iY7fO?RCb`Ru50>i53 zfZ6;+;ylE?Th%K0lLv({$?f8@YA{*iZ){fS-8y~HL!_T4>A%wH7mz^yVUn)$KdICE zKCIKZth!F}&(P_=(D~aX|Mil7uTD2I>bX+VN0Uy)capA_WF;|=S8h?MKZLNYx|M8* zx8oO+W~)ZNEYGgn_q0%PJ1U1iav9z%ka+aRJ`2x%oBn@|XMDW%v6GcN1gwP5(P+%` zO2*1}jCm8d@mmd9Qkk)O`kaMEKo7;TSeTI+ zlu$g6;@Xx|3@aLwC2qs>f!kQJ2KiM5u^HC+Nb{0bWh}uZrUm5i5#9xZM-tZ?FW+{~ zf+c5B2Ssp~Y2u9AE~q_m#qZ)`Jv)hC9fE#}+!Z!V)0bL(!KPIbm+H$heTnHyyS{XA zA)b9F7y09$sRa;SPB&zBdaP&naiNAzeNl!Ah3d#a5=+R)rCeXC^kuxhOwpHVT&TU4 zi~RXHrxHoE_Anp3q+5jWW*dw?vSO-@*A&gF{4odeEg?p zk``4!5_`vpew0zk<7^%|?@GRXwU?3=SQ^#s?uLD!j8vJtg|IiqYN<{h9pIEN-rJMtI0w7G1X*0{rEJUHKQ*{AKfct#!JcNzhFW~mJFjMVhhU$kRwZs zF`H1Ml^IVY&tR9)CaN*a0ggICr?YwcR>j7|0*VWhGeSvJGU+#l6EU~3ssrbndi^OQ z3mfEHdJ5~+DQTXWBu2f}1Qe!6E=h{O}j*x^22wt76WWjCqP)OlHWU(qVCh9Eo zIADf<6tfnp6t82$^fM?C>c@d)G7FLys4nXV0}o5SQT-AbwE9Tu6gEA~*X0|Ul}Alu z^j6BajIfhonvJ`&kw3{;?}^kGFQ!yweerO~8olj!@MBK#8Jsn&b@n}cy)NK{{F#zb zN{S%sp0UyVmjDXlu12`fwMhB0@nRFj1SV=n@@qz-l~6#BIxjPG^|w&Hldav7*r9F1 zTIW26ZirbdxM4Sv5a)RjPrab3-hJHufEB1J>ZLir+{Y8S*(oa{U2t1xh?5gfmU_C` zDUTjh!08M(=h;d5l5}&_>GoKoF@amd246VH5_Yr4L(jt*2QRGHxX2T6K9$O7XYg3g zrzCDeWqW9skg{MguqZR?9{?63t%VB1AYnE=0=tB2Xh8ogpF1FYB@5UR>ArvqeK6I) zcg9fHrQkKducr_ZbY_BZZ;)39%TW`nXZ+qsp*M0j<1hLVBfiyb_0s?-`k8+8DF}EY zw*rAw7M}tEX@H>UM>j`{ZuMHX=1W*BRuC!f6HA!q1O!)s;Jx^hF_EIWR4F_67^|p` z&P&7{;PFWb`*E2(^>_7Wk}O;z)Kb7@X*e|OB!*8k|Jl^&PlYTDUZFpO|31pAMg^$d zL<+J~Gx#NMB+nZe&-nA@i~HP`8XJ!S4jxT={3hd1WijpXn~Xo-V)62MEtByRHOOj9 zqktZN#YP!^4}a#3{L$AHcNl$1 zsGBB4vXv4l_OqNUWyHgCd;*$me{_!7D@U&6pcsqAk@(d*$I@Oo_WYC_pqt83Kn}%P zRdz~v8%_!q`eux2SKsG}Gq7~vK;!X}7-@(LkU5%HKP_VV^HjwXR{vk?89ydadLR^$ z>ioKHT`2@XBEkBszrd3z{9YtTbVFoe?ga9BBA(o5gz58CHU{@jAF9+|;+9E&9HFa} z(Jut{u}pZI4%E=Gr7&kpp_IY5#c||d;zItz+)|~C4NXDgsa%zjd1?~Azp8IOju&N4{rn>ZLCYXSceub z(eUsu@dGHEs6y;s&+$51C7;KtS4m#S4uJ=9 zgBS7?&Imke+nfMIu!n$5NF)G=ARPet7ZjKPL?MUBV{CXV%*8A*s~1QE@IuzXm_b`nTz6j|8tg?cAY z$;1+%5(~Y_Dh)wYU`ba3WNbvqgp5k+fy@#HZDUGpW42kj6>ku~F$NW_tf4;Mvip6B zma$6_{D61Jm6g|1S+6DzxOzPJ(ez=gv_}0VvQf|xx>seNU`e~>e=h4$dO!n;SLW>2sAvrv^g^%Saa5H|3Xv#2Tbrt-!58q=s<_!mXr zz^`62sY$UgdT#a1q!30T_HucmQSX2&=wxkUIaEe=86y|um-!>l$wC^a`~nI9LT%Ov zW%63gXfpVh%i&)>q@VCFLUuI&;x>NL;;#K5D_O?$=$TItD)SgDwDVT-E5;>W_qshn}g^sOV#fI;;FgCOg8b)!pFcS_JSYWjKKTU{Gxl< z`Z-r6{)uvF{QN5ZkF3VR0&)&(H5TT{hlZ_0DwN4qwjvM8Mq;Zt%8OYF^p+zrPfdVY zeigIWq#o<#k!goqcK7jgUT?$!VI>H;e~}~EM@FHJY!-JFNU?UGZi9eZ<~#%tQ0b{zHe`V)jANrP!^ep zVn^`nwzRL2_i$2y|k(`3I=$E&N3aN}1ILg9k5^wHot0aFOhs9ot_lkKY6IA?*dm z6TgsY8EH^_C!zc~ZUUXaG?C4TbgGwZZcR>w1Y8*;(%l!!#V@r;S7R7YTukL7*LW^> z5M$}BY_)aVpZqmL*mY~q5LhbWzDA`7Tbq##Rfu0rGV-7A)Ps;z8PX9uuKPUBEC(V9K zr!Um$AxT#v!EvNBs0n-*HVG20@==wNdvrX)K>1MmY>g}UR*d6|ulEuWo~ct0k*ck1 zah}O0EiEGKr5_6KyUVt6hKqd{E0w#85YjOJ#PZT@6v#ikZCwF5@uS0x+*@$CnKuNm zQu}j>fb@ZhPnJ?69p;Vb*+rp$URvLbsth0dx7cLkA_vpBnOro4qB$1a<3|1;x{=3` z>bcSzU2v!yrEql7Gu?f&yhGNKFz9%H6eqFHZIaOHt!PKbR_Mmq;5S;|kKi)-bImCx zpHlYUcjTd&>t)EAs0Meu1aL(BE=mF0OdLxfWgKoayf1xk(cyNv#(Bil)N6fS?RFjW z7$dh?AJly;Hn4T;%d;?o#l6%Xc0)gOR1DeYcni%@Gk(=IU-MWyJg!fA>~;HJCFc}; zOC2S{*L_eW-6 z>hNtx2E9|p4hA7a#o+ah5|;Q+lLMtLeSAawL24v%pfZ2&1(arwYmU?F_{?+p98QCO z&5lnzD(bwnTG_Xz>vVU&Nez3GeIj0`+j_IwbHn{k5oD?0L^l^Cf0afqZcf54Z3iLd zW6D-P5UC<;aEvew5gd1pq%HrAhRcQEh=G>G1{lnPKk}^tXi-%ldc~UrGrA-ow&MFS zx$I5veUS;Jc>(O03v_+(NDbTh+L+!A3RLbh>f3qX%Q#D=;+uI8oMUVBil;Ptjn|pg z;+2oB&JJqet;Rr&fJ4@5^z%d)2>Q8)w7Dxbc$Kk6Ap#J$*f*SpeM2qfrnbKGHsmr708yXvbF7e+)(Io~ASSL)~@Ka3E8$aO?<`ZOfZ6Mc3a-ASowo!i? zxrh>w$e!A!Z21#~Usd3*6v#2^x6^$okTbRItrF=&(N`5XP66z*o~Ho5TT^3WSRnB@ z$QSio?lE5G8ubxgLS}Q1LT0=A81)jlS)Px`bKl@2a^F>+{C3(ue0FIbap2w4OTo@T zi`*-!D8RSC91V9|1eQ1B{vRVp)yzG~{z}I@1q%wvtaA~RM`h&e8ueaYB?sr1FYrQ) z0ff{9O$SkA2YxAUG3UWk%(kORg)*Pq49WD3gY;TxR9RJIHFLiT-Bb9OX_JGAiY%@= zkdXvF$9%9cnd^_#Ev1+s=TPK+Y5E1xV%+T7Jdyk-Jy!mFs9$KC zwKe>)NMuWk_wJ8%DX6t3K;%=~%Un=5o<0 zw`*4F_0C6vtb*~&`MfNEdr}Ev?Q)iU@?L+v?6LHDVtl2%np@4tQ>tktr6_?FSHcJR zaAq|3%n7+!5$B4O-~tH@K{7-Yh13>FNLzRpA~#Aszj!#2XovM~0Zs(dD&9<%vw&8@W9{UFLT7 zI?b5$w#ysD#K%Lw5tpG0@gi7+JUC)!v@;+MjsO)-l@IM!;$U!B_Lvmx1pa&Kg8eyIiKr65m;8A>ne9wt zto(Vl2H^u~Jcwz45=+oyD_<#FG{z|8sNaLN8{hpmFu>ZBO1}HQlUHF({k!`0kJM|X zmqLqr{kqDiU#VZuRj*}fkka?dYjDf@rG6VyZxN8Iw_~_!gfkictP$?b{|TM1NOWD_84XA;>OoIsZ!d2dC}L;-BKSxwi99 z-Y?_dQd2KbXk83WwwGF*^HF6qaLdACI&jG{D|SMRu#(K+4}F!*M*THlkPr5^yV)DL zfw}FJm{uQQ$1@(z6*GB+2r2wvNn&~PQDAhW=(5P{=u)DIV`8#3;2=8IW?$pJ5|1?@ zx|DbqY{T&RNrZ^S(TLPSjPQeJi6x4zBUj<~ePoRUOuyp*u};uH9!W^NWGyU*Vah@w zH_xB8@=(K|Q#)C!E~u5E`74`(GyT?1yoNjx;>WFXS8@x#AM! z*U_Z`$5uMLKj8Rl!1|0(VRZOGyTfnN;a#D1EENKQflMgTR+iOF@>qO!Wus6Ysi~iw0ZQ;xpnN|XvFj0#e z^*QqOBUNRJwVufXMl?<=->L$Ul0f8v0$Ald02vK+R_r9cjI&gjAc?ini885680^$G z={gDv=RBf-9M=jXJsuar5rd>cNzcvWH-7{MKfz1!fDDcFMf@WWrtOlq{{DH%>ldlw z15(*S+Yy^yn2E1AW7$=NUdOve^7}_*yCV}ul)>eXDDlyhH8HCi+wp2E&}ST)6-Iq< zRQH73JmLL+3=kO*1f``MeD_u2byWt&CBNsB03x!voktb`ge}n?XCONKe87N@Oed7z zDKn(vQ!_fJGc_OZZn;8jSq7#6GA>edh1xcTE*lpeN*p-?F&yo_gVsZ5GbLyl)iNB> zTR<;v-f&l5jVF0NTx%7ov{cH~$^49`QaKNa&PWS>*;hu22KghNL3!@(TT=$>n&&lp z;hUgF+8-wEuM7tHBUwFO%kuecuVtYFX|Ls#BNV%gFL4~AIlcNMuVp5+=xfAZS%REp z`YT)O{xNrD9HYfu8DrI%%OLdqW?cRycjfQ4-sf6tys}n4{y3bK{rywU%7TplU!9d@ z_}Wz%ele#H22PAkRtTBdYduzDc$e+4obOoRFaE@gj+HH&fi<7_E2oN%2VxYJ4QP*i z?LRFK%{rlkYWOQ_nT!i9-h|RJ?T;GG{fKCOnf}UCo3%CXsr{9)S1wV`1&UvlfFiY( z<;MK;xdFY(t6Q?N#nLAB_}w^4z?{M_nt1^{!RL$G$Zu5@RJT+O%4Yp2e(fB@64v+< zv!$wx;w=7+yesFOWe)RO#tM9OOgVh9$vCe`q0wD=R3cW4 zL>#HY{)W;eJ$2EDuFL1zFk=N)hBKB29F0l5M8+53`IIVB1k?r%se}dBQn>*FHY^ymgYjUOKJpe2Gzw zq2?x$tl}}+9@4)7WvRBlSCBX?>~1u7-^h&NKJi=mT)DB#v>MWIj2V8T(0g2jIQp$sJlgLeJ>wJh z`hIo$x zeoc5_zhQRxjn!*llsbHM$73TzBJ%qp*^#s!-Uj_>Ta{Hyny=kj5(G+0Zu1=P{aBUo zjP+ubx;#6K3+9VuTuduk&c$cEzTONUxXDBP9;@LBlH9IV+n7K06~_EcSdeLB{&xSj zS}_2}nE&)*X{gKG)B)S+^ub0zZ*IRKH*4JC(z8(w-c&s%$8&ZbsJ$}A$ULq_1f~e+ z#0KCdxGxAhX45M4H4XFeXU_@grk{QA-q>GY5_M*Sm@l`$|JXP;Jg zRLt`I*p~86Z^FYz?=!*^0D47R_~>cIj~hMFE85)MZzYzJw%nQiG})mWs#_XjKv{@v z?lXK9(`snt8FR{ZE<#lG5q43PjeU~(WQn&12(n)L>QSLy#k;PjAcOSQ@8k<^QCUpG zLkKyjx_nryEW8J6k2yoWbbIpQf)CK@6<=yq)8>Y4^7Un49o#6}B2)JwF^1OD6;Z|B zVW;YND)-r{uel9}PtF7|7=#*F;tSL%bw`!Y!|=MJ;^H8OA-yV~b-!+VJS1xmI=pVXI*_Yxaxl4YY*25NpTq06 zp963Ec_PmW8e(c z!&^0ovHL&7(i0!{JmhVqR40Gt;a%W9?uBuMuN`J2D!3^jO{!3oO)4#`wq}=>2+L3| zECVjd(FZV~V0XgS;dSXQiVS2kNtOX!h2Md5SK?&yeDb@HN0_~4j^LJ_2R4E|PGj!wwG!aUY-!pD5g^R7k{jDSU&Ko6!N_q_ zQds{6VS&)CpTnk)Pqm}~!sa+ME;{&ScE|pU6d^DjF}Sr_1;}J=Gv9gAd95mD#rDvP z0Pm9}|ytMBTF) z88Jb-SI(6;#_O#@QEAzfmR+qlK`Ar)H&SNyKF1nDL6nFc3M(=QRbqJBjV0)5S@EMK zl=4`w*V2-;q@G`GD8E4N3LBo*m*@3mg}$`v%Qk(9>r0ou`7KmBWkOfC!}Q~L$>r;%~T&3@wWf`=G@KF4j{j$$Q-byrtEWBxPrpZPI` z-r~22;$TL;ET0>Lf4Vt3c!{*Y-78*NB z%zmjRrZx#Ja}luf_Ty})d4ql>_0pWG2%qfITsU3d-i3sR5C>~cgkE$XY9d(GH<%Y} z(Mu9;xrycugNGU&an^c?O;mq5R=XMfO%clQ>eE%Xjr!*p5OYR#-E<_R>s}-i^xmkC z@-AR)!f^w^IP%s#Coo`?h^p!Rkx1-=iww`K2D`jMMUqhEk&SW2&zO-Va)KA@*frx2xUtNE0s#**t z!}hYzR8RdJ5%|(VoCTW*OTo{ zmA!xH>ZF-6+|q>?t3tiaPNf<#mdmu*8VD9)u_do$hG+w88HyQsX@xXbxdjuDEo`qr zxs?bq#SFx@@4pQDk0SD5|M8k+RrdYUFT;dlnL`ong;N1WLTw?H^@mIcdkTpWE(bP# z7`&plP12Vw{?R%&$e37&LSmJqO_a3lpH z0%yY6t|qp~L62((+yOg09N`)sAP=r-a1v`d61!EILa=XT<%$YlX+~dfg_W^w3q_fE zH2$@RY{PGMUGaPN$eetA8mwopO#LL2ERd4vIx^XBqpu4NkSH~M_@Hh6Etv|*M29p| zG!&>@YQ11dh%9-41{f#UA$Gg)E6-6kY66jFWXxB*9MAz%HvC$#079_E8B7qz*3V&4 zX*=gN)PX33$OH{iP0xInNqrecvc|Xf07o&29_+Y0rq|m^nvYQ|NzqtMT$qW7VFl!q=8a4@s8!c6ck# zG7;%;HbJ<2m77BEh+Q-8-#gPqJ&|9kLL;MtFLgV1lFws3qf$JMkEiG1MpHkLp}}^@ zw|6fWJ8xNXPd015Q+?XuVslKi=S=t^SFVUWbK`@6yYiVE1qx<#F8(yrE7bn)_Y`1~ zIzpFW-K4fASinDEIzBV&S_B6GgIs)-T|sv1P1^Ga-LgO+M(@>I_uyzIB?=U^>zp{(YGZdyEJY>Ws0KG&VL*z0qu*BJe-ott()d|!ey7F{67-B;p@EEF zCJN)1i#`7K#1rJVTJ-og%GXn~DRcZ937%QU+@QEKvsT>(BmLgRoA5`@KJV zQ|KM{p*JyKSKob8x_IiVga21KcF0#>tSa(2K1+Xfbl+JRW2&!yqS?Gb?U;@5uSs^p z5PKF(>fb6W!j;sW8oxMEfg5Ri-J|(6XG?UE-^&bDV_``^Vw2b+<3~Ia-g7+tK6C!K zBXhFck;ypl4_^&+`Mw)rzpUgu|IB_yJNZco`SLRIpkz>KoHi`whEr}1YDuK=ni+=o zI3D_UWw8r!$(XOBBD0@mhU!9g=1~@D@H6ZJLm!C3Jd_7;=za2By;96)s_B%G*5c=6 zh&}6tFx)pN($CT71VoittM;D4WHFzTIrz0oJgTV-NBIB<>Lhz>(jiKKnT7#Uw zuGn1+)kS{jjTqj@oywoe=RkHMa_|x)Qa{3n+il&cVC{|EqP(e8hFeer%i@og{1)X; zrBeZR3p!x+M(KcmnTPsDW8VJ&M0Hl|C%b&ABnFJzv+H97}n9$Hxe3$h23=^hMFqN zE6^p~v&P#B*+Q_}8VukL)waC+#d0Z}-K%i&8Xw9Ev+{ruKAXl4dHNF>yXqecC;e%14Ts;2=uDtV!fo+GOJ-0GX)Y_t1e3x$R)Z&-o#Gn_cAZrPZR>MnDNYI_bcbB2PIOKgjU3=!st? zFBki`x_xo=*?(7B%hi^X%O+|l?%H&LlQ6Q(w4B_Jocs-tj6ac+uSp5P3&=NkM8ihE z;n``X<>cm&=9D14bFLr8EZ6;FPVzp{k__3zGp0pY3x;<)5YiCD8RF=zYZ4hb(NG*p zRGPfr{bF|VY80;LV&s6F9YOi4^pn3TgxTKr&1j8-V8^Z?9ZFui?;?Q`n%fp{Cln}4pEG7+QCqQ7 zi)qD-(`T(mRf*LRLcZipN(_H(&PS9)Y;~a$!>>{9jef&-0D-M(c(>EbPBhCM$n9eO zsP_e7*>7IK3<}Tb$O_IjiF-@C<2mc71AC9EBoS^hD*x4os`9so=*m@3s9dDMR4y|9 zP6p!)-KPhl1i>}*=Aw+@Kp$kBT};9jui8ec4My@_CVN$?Tk%z|%d93LGSNiB=N)f~ z_>t%i@mkqnpd#Yr0vlLOj5QNjNlYqozqhL=QGp%UNpu`cpG_Gd1`3#+Pppbh+n`B+ zAfAu_W&4-LOnsy5_v-eEeln%UQMid#jKyN8o71_3;0SWAPAPHBAzM>A8Uw+8vneK% z{1vbe6kUUe9PHd`u2$IF-96VF(!v2q>aTOIaEwln^L~j^G-NA}!3#asMunT*p>t8> z1Wk8cqaY_JrZ+OlVc_OajszG$7>mk-RmS3$6uMUtB+0wUAN_^{?NgiEb%e4?mjilc z3wBPF!*K5sFoCI4koN{2STONBf}GbV-29L?fIFseb39)k2)0tBU2s$H9}X#jXJWm8 z$ilWolv{$5X7t8syA{?vbpaQ{Y*8p_8onbMYYu=pUF`E0P!%6!ykKSg)t3Y%UzaqN zt_rC%((tt8fQ(C8fl`8kqSHXxS+rx+yU2y%!Zf#1MbA|o*$(cg0=oCas8-$P9s3{n z|GQ~%o5Z)sO4LbOX17;@LD1?C&gWv7>(%(^c9-sUCwh+~x>hjWXlRz=(c&9QaOzK%|Pt$|Ee+4_ zqE1W!s$nZA$_nyhz6bSYQK8mP-|?iZEX6hQoj6V+^?fA*iGs~bO8pGsU^ixei>2}+ zxrCH5t?I3~?nrlPXNLI)G2)Y*%_ zV)s7u8uxO}wK(AnPUbf!=;9ZuEq3z$=rl)wLqx;SG&w%#>eb5~#*=q3vlH6B)_%R6 z*F^XmdbL@1tb_ZYyYAShTtgGwwI_b@c(vw_QhF)AhAU6nQ(Q>&ZRT6^TQ=~s)B zbJST&>}iQ%m+$8E0JqiVE<=C+awAi5;W=<$cu}}9ygH$(W-g+xZme&EarST}Gt zg4EwsIbZghSkmN*$+t2hFy-_?@W~$?eca0lcE$_q zSU;|GPdy^qNRPG2b!_S}07Eff8py6Ie+0=4yER_t)pVl z{F5V|ElKP4JyOyBs(U)bxBA`mbc;gCL){$#F1YdbRsi1+rBFGLN+;JyCFdUj zueBNQvOfPRa6Os=*WHflk-+ObT-ciHP6)U>*1I#8{k`DwK}!Lbslf#;*6=E{;pO)U zdi_PvOJP6?y@1qLpx3~k_XgL=@EI?Yg7ZE$_#9uNWi0y^U&xd$9c5;gZ6P_#Xc?<`?#1K<{=zOFZ;OLt14)bWW5A%qz z-z`HzaEik`+SFkl@x#w(*qp-RtcPB>^+b;5{_CG|d*oe6MIg}|VhN|hzg=>YRK7!; zo{k5iCVco&W9Cljs(oa~2~~^V#o3st4+YWKy`AZ6n{h!7;?;JCdeZ{=WT)uQ$}|7l z`*Yh#{n_%fQ_-fA`lFmj;eYFP!Y8EdvzTFcjB!jBz7bA<REloVXI})FD{G=S=uCZR-u6(O=o0gGK?y4kHWXlrcEo&_Kv}G0i)mmQamE{y{cJ zb`l)g*fU8_wCw2aPR|Dzgr4&jVbqxwX2e|pqtqeB$+9(4rw7S)1=(D5WFBeT;Cx^E zi~VQ5nhqlWMv8a9td_V=7$kQkZ_SjSjH;b|VfSgq;#k17FW`8OjYr1lxa=rpK$!5A*qhUX6fCQp28alW`Zd{Y^v(SS~%u3V`q(X-0hlU@D2=cFyY@M3&Th z69WqZ4s@BWrqJ#FsJj!XCXSIl#}P!r9z;)(35A$r4=xN?TLKk(eUA47{fH8|BT%v1 z=hzYGw<9p*phV7ZI2iIE2!vN^nz1ffnU*IjX=R4IN%bd4*jc2LJ3etd+Y}+}h#ml)ajaXg!Tt}!$fFY}4@A}R@Er?|v#)sQO2a*KJ+=Qvy1w=*lJ1}N! zF~V)|92yJ_Z*XhM5#f8($0YFM@#Lkw>I*>5R0a+V7Fb)zl{p159`y1lJ8Fkz1zEz7 z|FEl&aih1YafMw%PC@9v6f<*(;70Y7{MrG8%{9$v%ZEtBt%|((7G` zU~UP)!Ex2CFe7*8)!vjPakz5xY0>Tihfj%HDu)FOLam1guD`0g#9mRw%xiI9yi!ZV z!6=+@W-M=^F2s_RI zyqkRe+%u_-q|SH)`)MhJQorw*>u<9-?rbjtG5!m_lUduJ;|>SQZPxRC(+_J z(0ZnagE%wsb4v7ltW5lvBKR?dFR@I02*`&*$`Oag*pP<74HB5%@FV@Et6Mh;E6}WK zC%_J9qX!OyvrR{pHyXtFLxjQNjNPu{^Z>o9dMf$V4!aU42y4WtDiOhNH=yea=wg75 zuK@@nV+3>zku)L1(cGPe0v%A~QJSPX?R43RtARLgbN~`w7XOxfX^<=!oq=mpY&1U! zKBu7ngvC_{tyORw6wcOja82RZwykoIRB+%aPCMdA(rE(6Pz4=$VS{u)2CPc~5j=*( zVb9&YnTOS89aJxKB|>H2M)|0j7LEpFP=yQ7?!QNo*E;IaL0CTNdhjYG^*W(1hz zk8l`)n}JLjmB=gg?+aS4__XI6|0H>}g zs-u_LF%#&@JL^+ju`^5Te?>D6;>ex6>+kEpWq)=$N|xKp0Dvb_;d(8AwdX; zWP%e?IbLxTVfV`cWFB6J)pTfMo`mrXI`dkS;IFsqP!-?vOf!;a6663d^-F^(S=5ti zy_?ZxbsmH|&h#5-yI7$#H;3w(_638}!@hqxUO8%#K{(06q>Zrhp| zeXAd%4Py-dM&ng}I6%bbdAG6j!ddwpyN!8Y=U#%cKXEHJsQkVLcEEDQe>OHpg+mz0 zoV5-pW#kEtv}r+njCtY4#1U@vNvNSmjgm*Z)+i2j_WY$DfUDGEc3QC>fobzkM@ zM)+;ehxbENpKi|R)A=)7Lzf^n za4+y`>~q zcE<-kt0}oewV&!56lX_rUJvMYx|ML59wkdN;f?P02J!Z`*7;qV`2P5i_$!ma5dFPC z`um_5?A!g_p6YL*>aQAyqtkEq_jJ|YTYCCCQ}q|a9gKgr+5KhWZ4Wp&(5Tt&uiZo1 zJ(>(-kb+4PMkxnOB2P*v5gOrOChVh`a^H<|MU=U~A9Z=?%Gv?X?OCx;hv^pRk* zY*)|ZXTq5dzwz5v{Gp4Dfl5(%vX7B74~O= z%8vua=;M4-UyFDpbYqw-7q&2dYnSv!$-5~JWsxp@n(AfpK@2KHL$5k&%U?LGy66mk zCY>SuYv0i4(f3H>gw=sA=G3^!pZ^Zu^32mX2l8D)r8$+Lo{ed*? zx8|aa`Nnv^HS;dhnmNU6=_}LMZ+)Bn+${xu>k-J|m=d#POc}|nAm2dyJZ6|_-B|^n zs)$zFZwUe6FN?LdbGPVKTV^U<>nzeZS5Z=4QYpFTlOig}M;=w`%uDUZDf+QoJvK^) zn@Ld%udp`J;;OJv%|Y}danIsjOR|z?XNavs_xqw%#30%ThrHdlGPMy^^1fHIQfHf` z_m7LlMurdYSFFdw*mNB;`yCq_nLQwngn;$F&vnAr?}Ts2`@X|ld<};}my4S_0W8>` zm4Hcle>ZYi{%OG*m1EP|&_QWia?A_3+Cy^kPnq9=B%R!;{h7+ob_++S*3iKuv5__? z-Uwp|#g3)qv?;D5eLhP$u|V7_@wz}IqRwaOhuq|jS^9jI(w;L`Gt?D#wz2_k@4sE z9>1JLBj?U!o@4W+^J&IJ1}~5-LZ>U6^;tA)(`V6$Xn?U6{XJv7D&s7gUpeF~ntHm9 zXyHE@uf}I~lu9S_04$;VaWMb{HA3XjIWw=0lZdiJ)sUlWSZzGHiA9}7zJM9&x6bifK-Pu- zt%G9-#Rn0M3AFCkLCLDd&+|(De>tciASAGsMe%=bus+AT*cFK%y($v&7i; z2TJK``;i(hI0{#&Q&h?z7h50LgN|A3v7R>?8fEyb!50C8Dc}XE%Mf;~V3W#Kw(0Sy zFg}R}$;Y|PX5hcBt*g9TscNII%kDZaBbe>(2;^03%R7_ zgCb)o7)RugfQ}SWc#To`SOFWV?upCv$)BHxB<=tWP3!6|{&S9#W*g;Xjn3}k7qTJ3 zWg!mXoWp5yZ^|G+e^DRj-ZxuTYWm#b)q8hr5M)cM9EY$#h!!7xiq$>ZzgaRy1J7eT zuq-~iMlJlmo+Hb371?p9U^Rc`k(?~!bj2@;8(V=Ll*u%s|4}P-y4Y(OEOV(mqA_~?xVf;&m1B`z!7m?roz3`<0f$tnT+5_KP916Z2z*mHOvs9;n*%Q>B37?-l{8jMz z4r9syj@`;xy;@0RNh_xU4*0Cjo1W-KU@`Ib6$@~9i~mzOwg#>@4cBX>(|9&qe=MEC z75Rl36=5k#Wb}*(HR1p2OQ#5OO0)u_U}T=*sxKWc36VMt%&sb9-meAA!*mjJkv6#} z7+;wgeIO5vt$s{F^A;|CW{;FrG};9UWxw5<9RElfF4pJ?N4MB z4s{xZvz?vtQ(zPZoh5QtE_YROH(u_hVA)V>-YA{N5PC%(S2^GtkBJv=$N}_OG6e&~2zeiENV z6#?syrP7eErM@&(S27>s&>1lP@PwFK`654%i;7gRn~ljl$%fZhUd{H3Sf}6k!@o&F zXq#Q;*D5p65_V*=Frz&ccY0NOnG^Riy1cC0EjH#B*`Zuqn!x~EQl5X2v;(HLM9TBAV@kS1@6Sa`p-r!8Uh!aV^>SqSO+9`Fiy^Yq^(7seD1tt#ZrP zskL-S{#HpEvwvGcH5nWwMrz@`^nUa#Io?}prvmqiT4@Ri;Y4WwJx=tw4ocp`3YGBz zevEN`(pXt56-$BmJQ9r4Po{@@eY{P4E|WJV)WHvS!c775U${zdkBddmFc zX2nG$m(DEvt-qB&BBP0al365U{gCfCUXYk+8bM~Zo&)EJ4)qiH0P4Vjy=YH$WXsv5 zEbTb1`y&@Wcns{1Txb^JTOWd|J^^QlF9pfrvvcwCGue%n4j4M*eANgZxmGn?CSU;i$b*MrJzRd@7MFcVR z0n+QsNTa|roZtcl{^8>d7iW>nT2ul7iUf1nVHNU{hbL1H6R5W(d_@$a&rQ1gvCCy(z^IHill4_nH%LvWG zZ$5DE97J!{5eFIt35$9Y!ALiqE1NLEm0p}}w$#;m?60DSq6B_cR6XJQ`2K`{eg_pDxgSLenU0@BG6(ed;d0%kGSPFTH?7zVgZ?Q=YCHy08TDIG>tFJ$Q(E!e-3P* zKL-xblLH6n&zT~3(~QD}IntC-Sf8^X^uR@`vT)S`C>PIlS|L{g0ecV7xH?2K@WL_t_~%!aQ5}=Ty1)~ri2q$L+!G$VP8Po z^zQ-HKZ*71rP36=lpW^8|7FultuM3nv(wbBu-YRL?B&S1GrtGn9LHzby)A- z93A==5P(aI;lOw!#z^MMBj6!_#B_R>h%)u9tU{c(O{?)w?iGFx=iE(dCHV~n#hCga zRvGdCy(^o205&(r)NbJzUyvC!%O}3@{B_NR+s`)UiQBMNydV!CDR=s)00a#aAyDNw zK9!w;=*;Gx(71aY7+ALEpvP!hlT+CklSS+QNuSn4tkBI zSWdOuDC7K%iC;*OxA?h3>ba|tpL?75DRfG=B^LpRRT}?uhN)YeDz_s3!!C(Tw7nFk9 zWlV^sI5XtlKXKCm;@`nkxtoblwhwgT?F6Qcq!pI7Lnd%>-w=A8__s#A9Oa?}6>A+t zS#|{P7KC=L2gmc*QJi^t?*@>%_zG#&xN|2{aXU9NCK_``WeEA9c3+!m&Jp09rF{dLnu@XN6%)yv>gX>1)jmC^$e>LS)AlHL+zH!om)i5e<>rI0M{-xQZ^xf(6!Nna=F72I z!|??1tsrWc{Nd)w*>69Ix$|0sXQ;_=8N$%`_wJD`}gMiHlz{)i+M@Uz{!9E9hR=G)#;$cjsG5YFPo`|mjHiw;6|_xjNa+M zLBZj3w3Ah$EC@!U5)1&;)^XPG7GLEKW8RGbif$YAe~}E8P3ephuz`G${1#=A^C7Pl zg=8fOz|0gnk0czFT_hDsw*t}pts;NnLqla7)AbO|D<^>cks4tWb=AyK^_+l^f6e*s z^wXh*-P79=7gL#-nCPJy4a0d(4CcpgU5cFs)MaKtl6fF80mZ!bH0g(FjpfDk0&p@| z)Lq<>=pj1Y#V_L0vWQ8fnCc8uj@$$Hvs+}$iHQEI3%deU7yGLT|`!Rq?4NpS2wmcomP9 z2$Xr8%tLtcOHEfZC0SyMn0)!$gckX%SbXtC0;hYS)@nxtiQ!18X#P=Rj}w?fr6s;Y z9w9QiKjZcBMj|A&Y2>Rmjoc26{4>3z!oNu)PZLD%jNf`&FZy^(nm!IwR548-tAsw5 zh#Y5F{9oAni2lXFLCi|`qR`<2dMf@ria?eLfh2}+XT0;)4B|-t|J&%}x%Yu2?^63$ z(Z?Tc{_mrYqgk*1U!jjzf9robeS98y&;M)m@m)n9PyDyi$JmFN^zlb~zJfmb{+A|z z|G`{U^zi`_;x*%K`Z(Qw9pQBbeO!z|>EBHsuS@kr(Z@V`(p`LEDoxYJ&oP=6@_kDB z__yfM%Ky-REJLj`Y_hW>z{sQdnovcHd?^;~TvMDMgzQC~RccT4)nsVE}dSET!Z!t5R|q*){yAslHvwWm+EQ1KcKKApU_I zu+>Z{F+DgMAKWffr^RO-QjD33aj4j_^gR=a)c~?VO!1U zSXeSP&$*jF=4-Nkk78+W;m6YgHx_fk<%ah8Dz{+oK`9K2)WR;39$Q{uTBzq@d4vZF zagOnvu2gvMuDN~-n%*fpMx*{$s=q9*#f(I{?eh!rXf6wBn&KeC0hW7V*(yvW-&OJ? ztFIpC(DiI8g`MLVmN!2#bshf9LRCA|IfN&zD{1CG?g9 z7{{s@2_5ir1-S!N(jr7b&LjMIj^wG|0`)sc{g$ZTGWA=oeup6z%`+>zLalxl;90Uf zPm?crCa8TxoD4;@ z|BKmR-^JD5g@cVn+ZhhW!!exSflAo;vBhY~BeHRJAUbrr=*W=z3I3`)4(cbA&LDz@ z8sU#rfuZLT-p&ZG;X(Gn6F!jb@T4NP?su_U?`dhm`(0xR!Xxo&C4ar0zvu(WK4H56 zrC0u<_fFeq^*n$cKMLmO*wq0nMW1T}*hxT7-~D`;rLQWt7n zY9up&N;Ei$bvhkO7hBuf*6wt%#kOi(&`Ag+>|s#?S`BW!VWrqk!fu+Suoj?j%DrOZW z^slq*8>JE{2xWwrYE+h%2+3AqMj;H1=)B!jL8a~?9LFsMs;vTZl5S^Kft@ew0<~6wg-Q3@@{ZQ;Q$t)8c((L@K85F@5q-9F z33o|%dseX@Sj8Sm7ZZ*v>3$@u*gPo~Io%)OTniCxEN+t?up}5Mty>m-$$1PEv1M>z z`dX1_!A+w;@JE+RCUdQ+TM9*+dbx;ZSCPKR1a%)GgELUB;}=aJ;yr4qFeI0@kMi7^$6_Fxt zIiXel$Qk%HD8^XMlt$>*y~A1!;#f}PIjYZOE)lx@WV>t_M`j^)(<1~9x!?xDAv?_9 zGYy@`*%>L3+yzYOtp2h0tF%+SVaeo}m@HBvE)6^h0 z=+kONm_CQqT&?wK$kaZhK5e#4^&eg$bOtD3`Z9ziOF8^p0TFG|3Se(1m|=o|6>SoH z%GB>tinmAjyhz^mnw(a{qF9N4sHqmkj^mNOrBrTuQGA!uM(k>v)I;}vFn8WXBE?ap zO0E_HI!rlA4~j+B#7@xN615W6=jcm{Y$1M8YY-w^NWN@&mp@XHqW2PGJj?UtyX{_+ z%RrdIw`u=8r9r2spjsI=8no}ZhHM^wl^F8 zpSLOaMfl`wys1FJ4?UC#zbg}dm6r`aN!jpE7y$o8{~Pe1sr&!66$*ZRBK7hS3?DJ& z1^iQcgg~MDi&NlRS@Uj9^}b30_zSGg$L|qpI5oGL*c&vyRYb9p@208Zf6?CoG_&Aa z#ZNHkWjxEAv0xsfVJ=a9J~9htb&f>4x8w+zi9>5G3mfLo4i57su+$PIkM8&%!&{}{ zbvCXA4&BdpwyJ*aRWRR$gO6UYEfnzEkkQf#Ja2O6O(aUDFlWTrsDMGkH%u{TvRpq6 zc<8@m|7ub5w}ttuIka79pg-LFwItznbHVF|KX&eWvRV5T{|TG+DewAVMQ#9SGacqP$R0G4kT z{>U{@P2Mu3#$d1o5FWGV@XY=Bjo9 zu4-H0s`d#sPC?ySn~nHBNc=r(f81q8<1*Z+76dK)6hoC%&h0u!8(M!eGv^w~k@EMG zs=}*;4-j2swGrOLwlIAe{Miutw-b&s;GEn+P!xi^v~YS4w^I&Vx7tgX^RlJrQb6da zJBA1&_<92mK|%>e8ohy0?Phe4BzIwYg)%noBUa1k9ZWu#2Y~}enfXV*ZiEj>R%L!C z2Ta6!4$~*N9^-1j5$>xUdz~lX@h5tmg1fh|w<#yo8vt}>2lLZe*2U^^Rc_9o>s-}E zOtCVEnwl-lSl7r-`MtiAoH9%RYEV$tyevuMQRtST+^ji_%JKl)aTyWGX8#kNF6Ix) zB)~cS9Ry-m%Uf?zW+&y*d(KAhsVpR>iG>7a6Ibt+m2s}9hF>S^H9mjMKe*Y&%{0-6 z?o#=XR;^nk?+tEVmpn?NKm}KoZqe$^PG{3NCOFJ!oRi>aTbyy}^z{)wYkC&}ZTLb# z!;8YXULzWz6ulUTBm$8a0*=2q8}|;Q+vETx$1(7Gk@YHZC5gg-jz@7^_6j^-mk@iU zVh_Z;@rKDd4wyso#z@_KCMT3W`kH*Y)}>!o1||r5}ExrH`CXPg~b{v z+fCV>RJNOk**#)nv09l}>`|IxvC5$Q)r51nwV!uR^sf}I7IO&2G(kz`3waxx$KdwL*IB3)v88a^cG%wWXFGTfjwk<;9o zqh%+%wjw)Oow!;~6(lf{$WFGYB(>ks<<*!G@LRbtUte7MQmii}`ckGZWAz0>panU~ znot}x5G)p(fN`63eN;#AZ8T?{p?xXn6eI<6G$b?AkQ4+|QpS9g`u*!gcgIBm9_`PVCe{Oc7!{`CqV|N0a_&kI0jAv;}9-*SRxk5+)D<~!`kh@-&gj&D&tRg;F# z{isSB0AJBM0dLwm1>UrE3cP9S6nN9tDe$JPQ{YWor@))GE(Knyzt*Mut0)DJAyE%5 zVOXiZa735<7GWH64J_dp^hVR0LqqPG%K)A^1+Z8HJuq6>R?IS`ZM+wjO z+JK75#zdT<`q95$ZnsM6v~~T`k`W#9TLjTRW+ebWA!tNwt7eFm)vP!OqA+)jByN_( z7bUS%wVCf0btoLPMl8D4a9d5(bm>5^bIjV%FlSU`rX<(2^8U1TU8xrVmXz=6xrkgu z>od847*;_JR5|Z^gT~X)Tt;F{-1&78fzx&@UaArAZkD6ea{s3H`#_Zs@#z^99zpkv zS?&D#QVK&GsZrU7r*>GT;BYHq@1zla))z7fYm*A=Ks98ZIVX}!E%#PQAvCk?8 zq!QU=iukXllitGAr@ECysUUZ|1g6%&1}ExTvrFiZ>R>?(IRHmyf<0Fu# zLd|B8r%V3Z@Ga^{OJI;E2L6x0mvp}lB=*K$k#zrc)qmD;wtVit*MB8S6gDXX%&Mfj z;6DWOzc{|d3ciN{$nHi&inp%(&${uy2p>{v*2nt*WWzTB`FGp@8Tbfx2z)ge@F6ij z@Ba>brh@NT0J8hfNV;Pyz+)I2*DCCyk&DJH{DNBeJql(yLSLB{vI-Wm%`!hE{!6+) zkpWmo(*17he>Sj6_{-0~(7$gW&c{)0BaQO4Y1_z)E0N=FK2LgXo1C-S_)A(Sn&KVm zpz)37_m+uYgg#=tPjT`)h3!sh2((+o*Lh1v>_P>n+#)84mR4f(a9k9s6ciX&A)Vb1 zuFty!M9co7)7pCEiT)pDBIC+6?>1 zd?9#)AY)186mSR#DvNtBZH5zo zQmgkP>Ztnyq?Y?1pedp5&I1tgAqZXW?JBK?bC+6;zO+9>OC40*42UyV4ICNP;k>^> zNVSf9Y_KI%5tKE}O@_AkBNy2|1z9!&N}dzTtC}oQF>LMP<^!LS{a=v?%mVZK=b$ZB z{91ay&_*9hf1h-FThhypZT+OUR$M6KmXs~B%-Pg@UA+4twT!ecnUD-Km7-$0^7ILG zOA68yngP{f%AF|OvixPbgxM3v*&f? z(dYHevZJKu>;KODR^cDh^BcLD^S%~Vq)2O&F^8F_9rH@14s+WR|t2u3*{^&jgOZ-*uLzwmo{tF_pLa^fIuWT0+`W0 z;V($Z91qO^x^g!P2iGaz*(7&6_$*#aHW#>7GIsD-ByK-5R`Y|!2H>rgBAWn#AFtEB znsPW&kh_iCvX|visw&$m6>K4+73CAJZ0BdX`p7!&u9jL)RL)A0sl9%_1k%b_p|CH@ zklMzn%qy-x0df7A-pkL-j9@R?td<4*2ti*yWuzAHdRA=40)Fu-T>| z%O~B}EKjfYN0RQcXaCJjHUlFJurIh#wv`P1DjmcAPOc-*>qbeVPYt4FMxA1|?P z9&E5~0?HrBE4)WNK93w-)b5>NDK|g#rugS_kjL^*D&;1Dk`>x8W52Z|+1pSW_+wv1 z9sjoN^QW|q=idw_%$je7LHGIWW7^7n#ni5J^Pe8@ePzn>xc zc<%LAPqx4MRlX|jh3R;7R6SVz9a1a$bF0=gJ{q+CCzo2SD*3SW7yn!JUt`z5`PdH$ zKjB0F4fy+hW5M4@{p|af#&T5`RSHHd9FM70VQ@`m|4(OgelIi(p=U?p6bTRL*f00& z_m)1?m)s!zU*eCAf1F8`X4=0YpCW>bB7)A6&xH?*I2Sd+4RL4qPn0zxpF?H~id^90 zuNZw!3D+|ILq)xzPbRuU|y?b60VG5E7|SYYLV#1(t}nn{LL2kt2=8YbBL?jLsb^xkR&eHg3(# z?fI2@w@eMuy3Cx7I!CK2+cI+=*Ew3NCErPX>qk205mnnCGjkT}oTVz~7nwO<);Y^m z&O@0w0iDyVa$=b|pV2v8D(BwJoKtj8iON}!nUnmbYN@GmZXt*B6^y=@JkOzo$zktt zBKf0~>`ku5Kf^P|eK zcQ`j!R&wrVBnE=B@h5zc`Pu~;>sr(+B!o`lq3PAo@bMGo1uW=XBUCm2lLjK{^@{yxVQ#MtRFT|in^3uv=DSp`e7aiQJtAo} zz5U3Em&&bwkD>*oZLi6X_*riygKlGhH%ZcF`GMI$HY{J;Bd@94os3sEf8@!Cy*RL*?2a8>5IcZSFuF8JP4G7)EqN^i{j^UtC<#O@6DR5itCinpjJA!neK9V6DS zYv$+jE2Ya;q7hV{*&FscXDa9qN=cl>tBKyNW;`>2%5WU>3NL6kiUoj!ch(q;=#}pi zqR{Ss;Qy3a0gUoB>L~chGYZav2e?wql=szazsPyty?A*L%UPN$P5K4vbpD-729~O! z6#b_lVLZeR@IAXK23!(y`*=yFu>JgQ8Y1rUHPGYYd?ha+NMbUcYOVKw0W5<(JY|loW&lolkz-Q!8&>7Vp%L8m6cBm(~bK1nxw*0 z%X#*-w^2|%7u>*Bv#-*5xBC`ugq}XFjvJ9jOD_X?m${3WZYY?}?SL0CS`J>U`>_?!=0E73tWlA?$9IVQ#VUn_BS_q+09|Xk&FQ zimBCNuJfkEAdoq6?HYb+#EY7VYj9ur{y=O#XWA%}f9L`7J}gZ@ z**InY4Y~kb@XE(5-N?hs(xiD>AwpwHQrZz7x-uV{Gar^^J}k|AcqH?oKl7nC^Pw#B zVQl6@cjiNRP zRptUoc_uTZOj1(1BKi7Bk|Kt|skd?^r71II?JQq=!zNuh7Pv*%e1_-L(+AqP+{e8b(Bj` z>x*vD`l9QV(g$7A9Lbx8K8VXVN*j1p?i!V)JN(?UrH@FGM7)Tt<6)@$dNUN4&kK zGNAVOA}Qa!^dsKha~M#&y~qmR|IJ6dy%QNwyS+=3?hzmL_FhHE8Ed%KeENx%GvxA!g@Xt%c{>GuEPBi`O94Yb>9V&C!ek9d23 zL<8;ix{~f&e)bV>@9$}#-QN79d(k5w@%BDL1MT+4lkRW)^dsKhwKULfZ!!GuPd?)9 zeTfFz?d^d7{qaYJm=_hHesYw?CH-9SpAg&W*x*#smh)2@>Wg7++$e+{VF`YeDXHOvh zXIA#1AE~zuon4i5ADfl^vd*5Pvu{kg6H;9&1j2Hiy)Y;EaU=RfgOUKPPM7G#tf_3a zoPzLO3NF!4Sq{?!>JOz-lgOEF5Sh>DnI-Z&-6g*PmF`95F}l8+ACBm2_jYJw zg22*1Q~VBzEL{IFj0Khq8=4dREH-q6&{EPd+@i6kGOHs>O@}QQE^vD>Fp_vFB6>cE zz1O^xd%`3se}6QdD830^d%JRdaQ(O$u#k2)i7-oS#a8>C5fM56&hX(RC;kv_roQy* zOTWG_;qZniZ|AgOyk&79%56h^Aoh7v5YhL`Y3IaI-it0_7PfAOhSr@d-(JrNem7_P zM^oJios#!AIRLUkG@!0h3Eu1Sx4F2~yvEOrH{`1?bLUofuS7>H_Q8kO9b3Qq1Of6HT1p%fW~Za-uEY_ zm8e6CH`QeomGoWA0oRk_FFbaD;WV5Aq5&fqTfN6@eY;zq>C_eUxu@^B{> z2i?@@?d2yRmIc#Y{LC!j=Z3L^iAMdSaj`83pDeaWo6WoOL^`Y@G>POrFcd?i4EvbD z(k6vS5x z;Jn5!b?{4t{8Br=e2Nyy_p^k(wy`Z&75UgYj|uasr4*%?M?b9P)7*Wxw82XAYazA` zVih0~3*8^_^AUa~*tp~NlfWI*hJZUpgF7-2#~Dq6I4VoEDZ^A$2w)D-M&eitU6AD` zA1}Udl*)m2VhH%1xPKy_AT$Z61fSQqr-c6rOGv;;tDo!}ddna_wdpfH+p=J^V$YyOeY$2Fww zoy7bV6s2OEgj>0`;QB}}2%2rgd_vm7fvraPS=E90;3@f+>HoI~bmgpG1h$f2{G#~n zR=-%VaF6zvU+jkYweZxcPwx{~+`1Eit0>C3CcN2<~?WQ4HEbn9FKqC7r3RrXGuYx@4J*|6$f&qV8SfcwbLJo;WRk`bJ=ZJQphzKry zQqOk)&kC17_+Enyp{Wu)oK`yo^X9!rteMp%tOY$2n)*U zZb`n>CFzrk*#S$$o|ya>$Y=KzLwz|J0E2Z7(6FjyV$bL#jSZu^#aF&I)FAkE91bDM z9i>ui_(q;05gJD9up!|U>{=i}<(1@$0It3}I0b|t2%CJ)Kdm-4^;qz)7IS+Y`Vdxv>(zpU*{dSl@FCN|hj zRV+_2nou?qN%!_xdXaY~-M{+==t9_f&DC3B$7~b(0sQX$%-~G>`XBd7cS5#4*lpd! zI<1?gxZF5LFV~h7qaS~SXHIz%>KJZ;1wx>((gAxy=D|Db(hg>q3!1pSZwNgkk@txc zhT-m`O8|!8U<#&aQ-gp;oFL+aE!4MIlQq@qTZrX1f@es;q1VjecM7`M12PE}S=91t z=6m~x*oSSv(i(=?)9_CR@2A?Td3Blxkyv_O37M3Rs#P^0D%Dc3XbQiG!Q7znw9aM! zw8Mv`=^rCfEVPV@@08%EGE8NaacrbmS}foSoiVT(CDcpksU_@lpS090pFEu_Iy*WL1^I?rbUIZxNsedRjO zXQmGwzQ);fH93h(jHl*)<=Wf6`laix^HIR#d}2Bb-QKSl!`E0N9DU(O6%zT{T-z!B@W{jI7HoS6`||QckPXIcHke z^CmH>o~ISe&j`RoVCXc;r%!M;J_DD{U4^r8mGIk0m_`t+XQuE9uA(gxO-k-^x3I03 z?O|81*(-MkxT_Wu<7ybnS=eIFtbznxw1tM!UDKqyoK4>Zd{1;{PUW~Q-e~<$@3>bz z!(Vkau^^SmO~RBQ9zrZ7lC_#0{1nz~&6j(EStv&lN)%RZ$?sW5zx$}kKW+tQyVoPi z>-&n2x*8X!GS=D|Uy_Wb6Zr@`?mjkFcG_9*%V(q|H7Y2Ouycirw&rJi#=iwBW){(8 zC@;+-OIC+&mApvUl$keL0I5hoo19H@R8r!4HYRoA)(m0b%7nqffLz)pEvh{7D>L@m zZ&bJ2pV18!x^-FVtS0~-4-DmCHtOk5@9-$*}%~{5U2C5yG ztJ;FOsy&&j+N5FQWTVWKOCjcdv{5$y|FE7F)+R$tT7rNAO9~aXd*@h>s^Jzow|6UR zi|RR5Y|di^g;h{GvAo|Mo0s!+8LycYdxqrnyv$u{m%aj9_ntMR&}R*D5f`7Rm)`bv zr3#%bg?^P)NXjb+B=DRX4dTS;_srCLd*6oaf-SUbPLG$DA=GF(LX0gnZ1`d6dR$gL z`Kfw-C-s!kl4Zl;$)W)`*fnN7a{I)IB>qJwR!HKFB%n0&iS`&O!^LbY3s~#BLg>4yrIA`C)thvmy;xjC>-h`y6NnH&K)v~&v_#l}OnTDh z_y4o`6sVDIvnu=^DSg=RaE# z|2zF1u1CYk`aRc%6|W1Fo&UUB@HNXxg?W0>7XK!S&$3Ok8g(F*D>h zH)fss>?f!f(L3aD?-Tq{HnLct3nioZ%(q3Hcnvd|u!ONdT+-UVFKM$ug%i_9z{%l< zM+tDwX@+3FQ}SWrYI{J=udxxSz~&DFr4DQ!yhUO-bG!u-o|R;0o>-QI;(CCR>0>66 zdGkE}*wmw(o>nHCTBdyGXy>Q(f5`LGk^`U#&(i`P5rHG%ImI6xDzct05fHH(k)nxs zu|uMMjl+Y`mFEGRl)MQcinH+tGzMD6H9|tpt-f=V5EEeuhGLW-aE8TALnxgQ%4rcg zNBQ}4uxF^%a__f}5{&DWh!w*yrbT)sb}iybP53kQaa(_{PiG^0xY3q9Kmh!KvP&}4 zR%8ej?}06_gptb=>|f_))u-j7Ke6j#HG%+dJEF4e z^R(>n2x?u~CLK?o7kn~{VpL#{2W-C|+bZ4htY3R_Ja zo>aUNv*;_rts)`!#?=}-9%Uecj-~0NWHe{`6u5K^R?otr3uaZN^E3 z&w`&a_ZuZ2kZ88B2gKvaIIr*ueaegK?#%rOhdTSb!qoWzaS;I@0bKYxS$3jcMhMCI z0S;em7GV?j)qUDm{+{1?$=iYQ-NgUhO@xDg&);V|oh zpB9vI5xtHmOY9dYuC!%V5K{?UGD{K)N32CmD=|}FM!_L+)>V~}UZS^(9G$qV-pG5N z@SB`HxJS;P48*RQKvf0ju^Do8FIPCL?6l<|7B=%UALr_i%xvc5gv8mItvEq8UM5JMxSf6x)qaf!>gmDgLOi7B88&{Fct*Dzl+zoybn1(Q{N<@z zUrc=w*HaSZq}S?m4|4K#zQ6pSjt6_n6|qT&yyM7!3B^#87Ccl;8r22Uw6l5S{^hu=9Kf_GY zo6WK@OnOz`fK3+h_2HiHYd&+S<}*_hHt9Zop@rMPN6lHr7K6-Ru>EuM8OG6j5Gyx2 z1by$|JjrEdF^eKYsJx3q%q?bSSI-J~v&{A!NgpdkP|DasOaMgMJXT4YE5O9d6diN@ zf(%HL?(+de=$HzjV}xqqJzFZCI*U@q^WXoT=6l}d0}AWYGZ=BK41I|2rSz9+Qdxx6 zney(VTXP|n9^@?9l#0`rd0v^5&NqMFEm&O6lI0*-u-zlhMnS$J;XP>HaGjv*b~!di z&NF*LFjmC7bl|BLQC`rZQ0wqA=&_V!8bhu2j3Wn5V6y_>ppzhW5xE_L3( z<%7zjGtYNOoMWB(ItVD?kRpltmBji%FrqpPA{i{z?RoH8eQu55OC;^!OMRBYNj+bq z2`H142*DOJ&R1>HPucS60mmp@bf?;VGWeZ;SKgR;o`N7a*&s$;GS`nkveP&xrZ{?u zQ7lokjEOO+-WmQS4*_(lsLGV~`CPQ#D3e^JYl(R`?j>cf#`Zf-`IuiRh0FV#jWf6r zEHfUF+87a&fj3$Y5A!)Mc}1jI?Skt@h-`4oPM)XZxPHlDaV`gi{gYHeVJQKkv-RC! zy!NwMDAdBxm_eKjFU$sFmbLW zq?KhMKbjrLjB%C1D7K&~j-aZUKBw>XIDbtH*b?7?OL^v>xh%E4<~o*VVm1k_k`PsD z5%Wn&yp+Uxm8ix|1UWyI^iPu>P;VXgzj&`um{SS$v^R&)U^VKWI8U9#M#sI*=U)F4>@Sd=tqXMxy&b{DNK2eNYQ`Tk&KqGng z0~^`TvshQ=R(h_nDY`Y{+~TF$@6C^)H_r5rAJJ0!R4&U;fHc* z2*bY1>|@O(Tvqz_gUpaO)he_W^m&VSXBDVY1#|?7tp2$s ztJr9(*b)6RHmleWDfWT=lOf;J_F3$7mbt|SbS%3M=jJ@^rVGsILl*n`a}Xa^v(dae zHs$BsU_?ipSxvqX&22Ga&2sB}qM?=xp>Kkp5BCoVUEqlxlZ=Og{>TCI1zk&U0b927 ziK)e&=kj$?XQQy-6n8eQ=7v*pj^{f@%zfy4`=E9Yn03A(Ijg1?JCGx~jEQH2oaEhe zyf7sP%mo1fU@C@klO!6B5t;2$-wb}kBj+0(FlXt{h9bzF`bAP{UVkNDtM77zPJjyO zhB6RwbuZYjKk>PzT>`;s(^ajSEQ!uW3A;sy)F?4lzR_Nhe;z5Cn2w#M_wmaiH>S9h zt+eO4VzLu{{XzHnRx2JgbStDz$?`|m*5GQFawC_#}vk0#%UR^>;BCgZ^&k)h^SzGux4k-@N<8}Jq)XF%Gt(A@D}-zH&qC>+T6?WD zTv)lDdF*U_NKKNwi+M7lIZut?F}dVMnaDyitl%+PFnNROlp<@{U{W4?OfQ>=S~dq9 z!P7Z(DmaCfAO4QC9P+T;teYbAVE?KqvIICjb>c-KHzgJwC+()h3iEb=mJW={G|W9< zBw+_B7;amL{Fcr{7;m2T+K`+E5q4h8l&7z~y-t_6gUH9-n3Q#}*v0(6gn3@dJ#4?5 zJ8PHn2);urp!26|0(~uYNdv8aH zXbTiUD_=x1C1qwayG2|HUP!cQ`snIwreCEz45PQZo@XKEEI!!4@4S(6+7AyoG+>^S zYmOsF_gnZ?_IrnKbT7gbe|h@?w?FF1*KgI0@!VgO)1$V6$lAyY;3~Sdmu+HgeSQGbBm-l{ujw9jsG`vQsHI&-o zX-SM&d@z~J$yre3X;|Csy?-R7rsX)K=#a!HPs8gJUE9#I>x70iy#kN-{u;@hmNP_( z3^g2mFuryt&13*{Pv~E)5RK?$vF>po5^suwLiW6uo71!Pv}96BOxFXlcy}k=V@tBY za|Zk_bWz^AFUxdwDQ(}m2b+i`p6q{eEsOJ&3Nl zK`?)rLVb~dDL9BLcs{QKWW%F`UT>Dsp|WnCh57DK&+T4;igfpdzzk+71~(y(1optuVb4|+=UW< z0^3mqO*-KLy~};QBDKp^(3%OUA{o2geR)#*0rPGw=8oL$)N2B{vuQG;)^jmG<_ere z?W)Wtc#sfk71W~i{a0&@&G;!v0`h{i`N4K+W=bLU`#D-6Hr8tEp!nZj)fx3o(EPt$ z7h={5`2T^GEjbE2oE&E^o~)6e{bec(3#*rGWm zW6!M9d*)yFs713>FPbr%STyzaG^UY%`?_8<(=YtsMRVm4y=Z(M0c9YGn6YTa%Az?> z7R>{Ak0uK2@0s`TIJo`Qk?SX4E8AU3_Yb5GQtRjDng5gZLl-h!V@+X_glMy964k=v zXkp?ly;;eAbmaPuOz$;Ad@H@GzsysXd@^f2`z*Mu^?Z%zYR@!5cZ+u~<69YfT$x56 zrx?)-jOaWt$aeFbud_B{feym9nu|fU;rb3o==A;GPJ&QtKLS>2bb7bZv^w|&^9+c_ z#20z)Bb&^__X7$b6-Bs4ugX_j@JLy*@~!aCRU(dx=Sa%l| z7bS^$ScV*DA!p)(S!i1`c%=gMAHp;B7Fxz+nPL9C})Oyu0K}a?2ny!Kf)LzHgj3x#Pt5?i%uCUWhN7#zv04}BChdAx`U^} zjtN9QVK&P*1-s0j-8Uo$-eF@x?J19M5!PrKHuh+oDd}xatT%-AJ7}JbI0>y|iLd#= zV?Tb@lP!y`^hfsL>D6UKUNu_YDL0~=lW=+dT<2E_k3A>8*I64!M^GED^v9~Y(EQ;f zjy=Vzfl(XxO?KW^Gy$h@9LIxQ2w1nh?#1cl+&6{|iZClf{+S&9irf0x`VM05S_Z6j(I@v_3 zJ+OI7AuxY+VAKlLu2YX_mq*&Q)$iEhA0-DeVM+IGJny%3h46SN+Tx%1`R)*6fq7== zSYzUyz0Stp(i~50nriZEp2*hF^HyUwbINByz;Q?#DUD4;joA1OBfP4cZ^{AD+qyk{ zTm7-STt?)b0rZ_O{I?z|jM1FB0S$Tll zr$y?7Ty9~dMX}JEU!x*)u()mQL%L~U5$pZj? z?9yJ}sMQtsPjXGZe^I_m4b6yedm6eOc#L!!qpx=PBf*X?D)g4{6BxVCc~50WH^2T4 zTz#h$!)|+E%KGUV^xJRJo?GxHXg0y<=0Ei?O|oCPH+QD#bcI}kssC>mMs zi)bn_b+C6+_Lph-scqyD-d$#KN#=F1rD@cw?y~fggX7<3dM3GlvM1Ns$dSa1hPI+j zq2n)G==cBv7ln?CGWeBje7s&{GK1se!o`{R_{j+Vt(xzkCpVTTh9j;M117 zGV$pPV17;C68y=gZ(}0OJZSokrH<$`g1wCD^hR1kFAbpI6bT50*Xo7PXcAj6!$HKN78hRRuEckcMM5#aJVs^~ zUm7M{4y2ctLYK!zwq8zfAxMC3C|XmLrMO?vnD>1x?a;fg$}Q&1C>+|MhTqYT({uFW zHuZ?jA*~KXuj}?jFT>83(@mUnaCxX*4ee07x{?7bVH|mwzu5LowxTviwbP|2g=9^gE9U2jsc%vB_7MNJo8?2~| zRV3$nM#U>5aro-8P-c82s)iHTwgT2=j_~6?2l1fVRc}B!mGlIeP`;$mA3Dt!zN_Cc z-*3dG(|ROYKSxrbsI^Y%#Kmng6|<->c+yw8F&T*<%h|E-l|<#EX&sf3mJnW4=^b<*(D)qK z$yp=&bF4dCeJ!sIrHj7pYv~_qJhk2;UEsYs{4rmz_5EDFKOaYvpg6&9iwhl0ihjcK z{25{}d7aC$14QX*FH!&m8I_miN`DHi?PZPC9-W%AzGnMS%5+zb8tH-&UNwT^S`v%D!G z7H^Vz)<2=h(GN+F#sZz@k1iYxcYQc~dE#?Np(ibQ)e`9)>$FN1Oa7qP?ncN*!q=Sn z^dA+r>rA*a@ozT$7^BQl@ZF~QnfUHaFnL3LC;U8uR>5~&@bheig70SJNy?OBwHU;B zBhA^f?6C%G@wN1T?2b30SLX}k+mem#n0xT`1b&10#gKE<7PSgaN#Q%^wnO+$eUk0} zRlnmUW7HOooHJr0e-xNFetWPiHaj_Y)S<|$p;t5c`GZZ#kSE~SqFLNlS7Ip!Oxsz% z7c$FmQfcXC9WOIqIR?;$-_dT2f~r511*s=?mq;I8^+XO?YnAo2gSSFW0Y|4WS6H;V5~T z1geV_J@0EdIMkO~sdVQWLgT=0ZkF%oVjyR&RAId&Ww^akyXHPG!P1r=4EM22*V@bU zC6D9KpJkOEaxNGqtMt#w$@doxpM3wcLYI+59ybLcpjx2L#@$RWpS?(5{a1^$)n253 zoSL;rv%8Ze`qe4eIcuhra0-ek6|Kec3qrFl~ZYeun+_$)0%WK+P7bOD=m4AFFLIq9KAXCTUO1t zv+?s9BYg0k;1XYSQG2&BdSlZ4XK8>|bMz-)=&2J*ESCEt{#I(N-VIhq{B!7?;Kz*U zMaJlM>>o4VaaUDJx7?IY>s35GDs05TN~FOQI)_3TJooggk@Z)L0hz~aI(lnx}B~eh(*{Iz}ug=0#p!@$g{{{+Nv>_F_-jc zLT8=ZBHWhdxQIv;~#SNOc#jCCKr;G{ovJyt=1d zq6N%l)RED=jQJ;$iz>F{dk7#Z7mO<8f{GcT*P>DvzvD{{D9h_EEk?wMihgwvtUIs7 zAHB59h<<*8aoy)j{IQYinH7S_#66$_Gu%VLQX@R+A~u$L@>wH%NPHq)9x4OM@+~T_u z)o`oC-MCEx($;mVm&6h4w)OI?E0M#tx2j6_&^zzGBk$m(PRcB_4`?TyvUAncie!k|DIJos#wgzy{HzGAE&pPFbo^l!CzyP^SZJ}pJdfcuwF@YLu)X%SK;&It-^&4~@m3)6j;{~C!1yQP z{bx7$w!AOb^}a2b!s8+Ge(0pcr@{R-R;FL&j}9?XDhG%XKV+}3r6tcF z{d|64+$MSSN56phz6JheyF41vJB#JfH|~JSwYfI*F}DxGwP|X8+)KVudt|<6b8P#( z^xb5kln)0S8-1htXf6G8MXdCfmrtyGtt)g2!OdO^Oq^EQ9XdWRv8L1v9pjt0sI)hD zwufWCp&Q=wMBWZPXVLh(5SBNI4$p^ml%ny`SKQ}=YJk3nYJk3P&Y=44HDje+Z<5uUHAut*D>arCL}l&FL#U zEUa0?K1>v>U&v1nhcCk9|5LB8Wfh&x?E4dzxOXxXs~8HE%LTzE2&VLpfa4Wkcoo#&3Rh1xb5{iDW0ihE zMpzUH{@5(ovn}-ZSo*uz#h`gy#7T-vd*Py26?_89i(x#}f?9!Sp?$BgTR%auA^crR zd2|h?(mN2OVc%u3q919iyokS3wz~;g(OQj|rvy3&r)42=cCA*_4(<>=OQ7@UyBujn zT(wMr+A|});MB&*N}K4(ue(F6`6f=zufx{>n4-R7|2dOEU2(_@pVNRD{{l?IYUhKk zh)O(|Ck8O%8@{HpaZSh@U`h9F2YIXsP@?gWO!dC5+mH2y51_Q(!y|jAfB0$)8%u)M z89(s#9T0{1cjH1btUv}Q7mcOBAw=$2O?4jUx70WKc5v6~ZQK+p*Sa6UAKijKd{0rc z{A&m}MLlhQBJ|^Nn>~HR08D+t7g^)$+X1576o_ospsp2!xZ1bnprEa_zAXm`OdmR( zmL$fd@V}LHh^CSoEDqpmGMGGmMIXS5<@GQN9UR=|A;O-=TAqlhwFsW zC=n8^96}2cEtA;7WfBstgpk#YX3||aDNS(DJtcB%!QU>h^xC$Spp7qu+Nc(@Quz70 z;z(z>8=Jv6e+P55nWXcQegdRnO-K}v&>aSn1>>pjl={9c|49}*!@ixw%kXV7eQ-A| z<`7@9!{=Py(YMKHZ>LR3W6L}2F;A7z+ZZ-ey7tqUF~j!vE*NcuS2&5%-RqAna0McL zKF6yB7B`;imG@AI`N}sEZ#=&x_&K+1G@x4Ua%YGPh?~_tu zw`#8DGsd*q^`DSM9S(>OuZE1EZxFZ(vHzIEnmZO+H=pWq zu|3vvJy}&Op=RH3Hj178GDjnG?Cad^pK?M&h|r_TBEt*cBmoM7cl4|j@S>-~jz~R4 z*fr{Uuto8RJxPmH5xQ(uW$9UXT-Hd$c_>N)Z`ZSw*)XC3h;0T$k}BkmhUWRt*MkPP zHjM;y!JFxa#(1!NnL|Dty3(o`Lu3R8*b~Rx9*o~oZCIlEJcBn-cE%98vcJUKZ;Y3+ zVt+z=RZITFWb-B08L`tQQI8*v;VfYrD*UkvpQo@t7A!4Goc@?2o1fyPNC&Su8^6lL zd(5%a5!$VK3YuQQr9XlJGT(X3(HIY5=b*Z92<=IHUUiGpHB4MG=^0^DQvJbGnLmO* zJ8xjNY<5nN9>8RRF9~|qjj=uua~A@o8oVCy&++{QFoABbwTgMZ@BfI@X&3Rv#ioUmu zmR-kvrPXy-PEKU)=oMdCaTjm4m?z$BUOzhFd=mWqWUfbvvOUhW+!=^Q?yj7K6=b`y z_`p5idx%NAX~CaCAH(OL6X^sSIG>DvC9dlzyt(80(QBMfuAz<*zPXbl{@hO^4mr&3 zBT?=11icl8Uvr{Pue{PR`Tok>&wxvmO_P6goT&V{2z18lnI)@7&k^DW|5^O4dw#Db z@bIV6&42;&PGBXl9a#Jccq+1nx*;BNolg`EK@!5pV^v8E>77X57rc_V$cUz1uBZEW zIW0f<;P<5^2w#CQKK>~M!{|+Vxw8~XP=~v*W%eN85J?;$n?HC`8~7NqDGylg_~&4^ zcE4&#O6y_!Yc*W>=nuhq!MkbYAGlLCItGYpZ?ei2?oS4LrOcEEHfLC z(ZK6A6VHKT$tC^~>Nk+FShA|mUMCeu4}bwr=DW!o4%^{uEFdtVT`b^Zdx50M$e5! z=7AxYd6kUW>7#f_5J-xj)Z?ccdDB>P9D|zKJZ0ah+mB(7GmY2mLTWtOI<$eeHPbPL zmexaNGM&X(u~&>0L%mvfvk-&DPb!1OdAZtR@Z`^AQ9cu7u+MyLoF;vES}_n_d%VU< z-RW8j)UCAkz5DbUJ!1aJn-%ITtA(=2C$Iw}e_{8^`vFtO)66Vn2ys|x^(3z>HrOi5 z?q|nhn;0kK@TkgG^UGZ;b4@4~@wA!XX(r@xj`=cfLe3|k6TpR~<07s5H|MX}-|7gS zo!?PPWTdtV@wd^NezXGW!hM4JtV`;ZT$$x;k!AZU)NYS}ELtIZYqZziFB3%~Hq)uc zsZ?3=Y&>@T_Ry%S@;@@9MbUDDy^OZE^R`WPG_giBV}V9!=jTDNd_idcI5+1CnZu5*2%fyZougY= zA*n?0R?bs-+eYiuDw_7pQjG_zg=obcf);VJkSpCGmGI7ul2og92$HJJvv$es(+GFK z2H||dGhvly!jMUhP(H?G1?#?>pOaZ+fPIolW78U?ra3&q<4KqQo)?X0+>0)~=czkB z_Y(v2MJ;Mk*;{$%MG(Nsid3yuC`^^p!|lSof}ku!|)yIsMnjP|J|JVRi8&KJHYCpZhf z^J`OKl2`)aen+$98BT9Mv8Mb{1r*^RhR^$gVMUjX#vYQlBfAn`u;qhf31BEG5Kus=<30GWLa&_fPG0gx(Ok@l7LYxH#JfLNMt{Y*Qf3PTX$9 z>f^{cJ<268QJOKIBX)h4#d0sg;*zt|WGsWF7|Acpto{Y59<-NHeTVRi^HKNw`rPz} zlXNdUM|PUUZL-)hq_Uzc*kdNvqFY=m9Usd(qYYAm{%`o9Y@-A9TSwY2hL=k>=g-F) z`*G>Qr2D+A4=2yIlnPm2&BEVV_&|8m%$@cbYR{2Xd0NvF7FlG2?k`;I{jt(7YWP_H zk|(YNVz%m!-j+|eGG}-l4sKhr2DhWE}1I(3psH^7^ znfrYA<~91eD!HZST$0VdE0I^b-IjaDlL#P7z9|hwTYP~`?#M7DU6_*ZESyrzA8}ap z5j`mbR5)bOkKaLQiI9o_vMFwe%XQ-~R7=2$-NORd@5Fo+zNc+en>Ztrei|o-g&-OO zfBGGAADkPA&d7%c5!PtuxYg$v#o?V78_{C%#ZR0Ph|Dg&JW}%sm#_VjV!nb3XP>N~ zCj-&CVoYybXdYhkZ6RvXR%dt-`M#DnhFu<1A zqZb=3uMaaEhvqJURYiGFzTNpC{UFw775BGiAOwdDieqD>HT>F;O}lI)-Wlpxo^&<)7tU5`}opUm?>TN009xP2ZN5B3t&mH;rwmPv!&*>GN#EG$Gi`YuI!W zZ>V`bV16%@`CZHDL%nbZ0zUM|iy%jzHM8+(n|G2Z!j+)Prv+8ME$@H@%oe}ed;*cf zzLvL$!XSd83q<1hR15X+53$sS^cf_VwxX*3$G33}mS934w(ejVfqyEXbDwlmlT4(W zm>T-bxwtRloxpkkOZO-8J(yE+YJQ0Bk1-IYjn}h2qX@_REe!aHz!RJP-tk zUG^AE5Dy2o6y*5YD+^pw*cVw;Q0$4+6qI-(l?7$O=n#iKGQCqp6nww#6VmjF)8nCM z@crqMwQw6G1~y#iLF$*`KGc=?83zgg#PsI*vjy8-Kwu;_6Jl3MGkS}xW-Q}tvLeHO zr|{GXETsx1(%9vTbeXk;a!cerUm*|?i6QBZoRvb3=)%7Uh2V?)^XU@usqIVkWW7?lr{E<@oMF#xg`evP280)ZZ4iRGs#%Y^BF7bnRashLc7mQhWEg`c`Nq}<1~9WU%L;u_ zEqErwB4Hb1STJ<8n0Ex{(_A}hilQ-*h-hP&PG}#~UJBnTR80tu;V@L9Rt2zGHeAj^ zh}vPYFGC^3KbK+KV$kE0GcSUGt6e%iHstHzDURGfF;2gVlMS7 z2?c}$-0>0od}eux@E$rxbH+K^ol%)NK!=H$!;e0ZIm2%Kc$y+nh7N=C_q^*XHkZH% zg3ozy=z2rP$odiy6AIX*yCS1%odH{a9A7vRUbylyRx$j%9Oy6znD6phdLgVDWr4}J z(sNbrQvTrGG6ZQ`ylSzLEpjA6a0`U@@E}x5Yp2uQ(QUWex zbrO##2rt`E-<1=b!#J<;cntUf*-<=b@9N zO1W)9FN+jpq>_R-!=I9}(NI;-7;cbgF_Wk~#$Uwy2;Vz-n5t_jNhKsLBdK)GyCM~A z=GL)QL8BZv6@MDzC6Pk4dwYc^<=W4M-U!68s_Qb%1#;>Rv35Cgh6XaJx10XUt-WHr zf}1!D7kur5oy?!6TDD|o(*xWLa5SIH7+E96Hd0g~{T`TLei4x9D9f={jvlBlOS;|Z z!eFPrpCN@g`b!0F=%#KVkQr<9G~Vucjj@#O(!!%1QgaH^|AAL~S}1^1IDJ3UKz35? z*G}l2haFaks+k7uuSylkh}^IaT8F*Sgu?;^A!IY-6sgyA=-#WYutlKt@v{B=b6 zaD9dLFSiRbH;T<~9>Q^L*8u)8GyX;DdpHJ~U!rv8_Y@)kzk^HAFlm9LjyM52&%3#C z;B*c*FO(6w`*ilmruf2}m3`%~&~-wsh1yKa}Sakv)7%o@4H7Ti9}9GUU;``y?qczF%x~A{RFBG;Z}WhtyIjZ=GS)zrw6X zuRxWvr2NDSr*a8Cnt0Cs{B2k_4@y6%M$#EKjkW9D-9WwY*=;4VBbev&W?~OLqIUvT z0wPTny~m0{^o$bXIWO6cHS6D`8<<7t>L(2zgy>iak=|>Dzm~OWUy*OLbPv^HRFRxn z!c(?t5sQ{6v8WC}^lMK2Waln%1QA{$-P9r4r5nTy%GL~W-i3VSG`^z7Qhw~2W3VUt zqj>?E375thT7aApxI@ISrc0qwYH1+W5y}K&f+KP)Wbch!4X)O^a=6V%3P9dy`^%y@Uj6#6E_5XeIXJR%-}!a_tl5-d`b zigu=EmxiHd77){_+wNibqj_`W5-a$MT*T?H)jUkl5a=q3Oe0aX*V!~eFwe34NdJhg zI$9_}jTpe5<9~J7Ry;~3|MYr*YS844S`VW1gP@CG8`f$T_|%vNhpUky%>AwSm-H$s zL&5=s>pN8pzUf`8&~ClM)%PYoYscXGM!tF-EZ!UR$s9u8|Ah2{!)W)Y5PYQckzxg0 zWgsG#yHr@dr-lDDBDp6Sk>lw#nGCY#F2FZg(?-H2nddfH#84(aP0fGy0Qkvcctv3X znftok_OHnMnf;Kjlk@jCh^o;QsUpk+cT3Xs#_|i3{y+J^vV85yv1FL>?E|n7}%uAt}EhMM5&KmY+B$v$rVYF8LldL?W5A z=Q&z?B=h4afi|$D-Y-Gzpj@z1T?lE64CxVRakSw4yP2aHu#Oe`Ndq&B zeC7(2#uUZb0*E#@OW+NSAO_i*u%I$J5GKW|yk|Mg6LBP_qTTf}^}WT^6M}o>1|{2c zJ}ZS3#X`6KhL_>L9HH@0K#(j3D>~qEVq!)pE306)K|I)>00kNa`y00{2Mc1#HXZSX zs%@!>L$rKPK1BuIrBVZbyzT9!5Tgn6DtpmYkWv}(2bo4C*5=(%e^yR#xGMAj3X<%J zC!DFqxfL?uhI=%aZF8u~AyajK&wM2?xlI6uEcVH^vY6zz8iYb+SYA6-C9%Fhu; zE0%W}cwAu|g)W@LCBv>oFwUisB93Wa<>wp>9MZ2-2-$RkkrWyZL3YRl71fK1{FeBX zJ@cN))kA&>^UV2|teK})EjwMAN@iu)!8D)IE4k=&8S8Lcg+j2H7-mep8*q1)8K?14 zh2WzKAxwb?I#xWaZMNzH4FEnL^czu*cRZP!<1DOTwDO);x58kRkf)XxGfv%yFU|>0 zRH!z31*X;U;Adq-nI>ii@vYR55XOr5E>OgG9ZNOTn}8B;t5EY(Uwi)Ar)bPxO=HeK zDNSQqC1GVM+>%<~tFf7YVcy-KuvMnKTE|<|^XH}tg|AK?Zc$3|MYX{Xps!N+jq*0U zLMhT1MJe{0MK@WL;sBIq&ps9b@nPA8O#-Eeq-rACx!dt)6myoQS1eKe`pr*OksG-`k&(f{%Az-*@txN4n zWc|fK7W1e?PP~nf7pZmeO+Mu=R-bAyb6^|^#2A^`$tcdm>!_7hr22BsKw9e)ILY zmPLSS?x6i?y^nFl6Z~LM&j+9>_R> zuE0E}4*R=0c(d9r+0hV0bl|vk_lwCf9z9QT=mn3B#zN=-0Tx**V;&Q-zK87E$Eg1L z&sR@sQN(?~6mu=*wV&NPh1#;BOP2}rd}fU` zeK?h_A1B+{2Iq{=IfPFr_0`zZhpSii9HQc!pdu`v^`x}8!8r}^6RQ)Dj6+9G+);`^ zrR5U=T_f=gphR-HfJ=;0Iea%U{$>b5J3Dxj6DwjTwCA;>G>II}S-(SzXvFbL8olvM z;j4UL+9y9n*MaOq)&&xifXl}O6d!}1DNjSBsR&7$iYA69e+_WAwT&MbF>&Wlgdk3l zQX!OYI`hAAfz2T4;)@VVr2}TBdJuUFnG+I*SI;}fZMRT{^hxo-rRjJ98$^O0 z8zAGK&iJt-y_FVG8*~7WE-(ed*`8q4OE(bH2ErR34CQ_=Ofu#xRr8-q++@dIn z`pqTT)G2hKt?-mg5Aa4;u@LFI<9u)<6G2q7XXuu!g*eRRA4GfFf}|4pWdRR|Gh1KP2dl)J#4w8vhB)uL=qI@$86RC8jp~qbv0P zA0kL6o>t|;#m@VmGgp7eJzI3t(`hbc56=xbl{=4h4T?KtAvLB-ph$XW-x#PlmISnW~c>(=mec{?`2 zx}M|#vdPKOL;WP){8TWfj(tjk)%}W&cqpR_z}Q_0l}oQDwSTjF%kEGC+}tK|cbC{S zcgpg%3wM8T{lk~2h4JXf2KjBPEtTCNXuW|?m+gP~`h0ro%BL2TnQfMaW7jbHdJDRg>4tw$bv<+MXj|13j)zo zs6vE3!6?T#BlenH#yK|HAh*fl$Dv~x@*ym*KOG<%IV3mKw=eY(BTn0YIbZyHTT0io zBvX^b1DBdYHWX8T^}%!QSJDw@rTbOgO{(p>2pWHa)D3l>8>4AD9M=i9|9Qrlb65)H zOJAPFQ$FbC)0?3Xe8Ae-+2@!-2Bgw8+in?#+|j2sayTCF&uk z$}Uift%Ke5zkUT#5ZM0gLFUI9MaN9r5I>kjS5Ynd4r%x-!#M{^Zu|Gi?{;m8jtdc($DQ1^wFURAF}#kb_;=z7iLvBh^+|!;UO3$9*DB-lr9vX^rj@ zy|8s94m$2wV~d$^hUOC@0YbOv(`4HJR_z{wXfBb}cLbn0qbYxng*CtxT1VJT#Z>vb*S zIf8@6C%4P^rt%F%iD+1Is2%__s|IkFsw8q8eT}C2MfOr$`kRg{N4`sp?pHv%N?6(@ zJkTtyB*#D;R&qe62MGn;)2S%2UY6c6vGk6Y+;_ZI-wgZEQ{P;wL&d|XEQ7OZvg(=> zev|>uf=BwX^Y*1b$cq1oxKHhtOz1L4w0E)(Wyc>7Hb4C*^20LOW>~<=p1MrL)n~(+ zB0z0%hVZ?Ogb%s7BpJH@6Ui_HXjq-+gr+H8bP+tnx8Zy+k#6{kJfsAOBkh z?q5==SY{==U*+uefykUU3GZwQ&phhyb8i&kP|HXCh56j%_`YzN&nT^OPJ<5&3Fj=X zU;T+kOPmk5oP`{lF!U?-_BYA!S@n*%{0|<%I8lUrq7)(6=Q$4?4`cH-`G5TBO_D7$ z$VmeJzt^Bri|VCecT;x-kJ7OpW4Gb~R6r^C*$)?Zy0j{>C*OE4`E}2qQ#^sSI)AF% z18+DF(;a4w)AlOoZs?)}Ji$0sG9zo_GZsgeVlUZ6ZU$|4$Ro_xC)bMAxMsD z{iscfv7`~vL{BCbCUwe*ljHCCFch%0>)sX7XSKCDRWcP=DE5J3LEMWnv93PH8*S-*1QG*n3KbN!*TnKsc63z=R%mnSg|_xsTCu zR)6RB%dlbkoICk^kyK0*@K2K?tFR~_pmS`cf_=NdzBDPYZx@ZXoHwNU3JWPHt z4IHR4bw(hertSae3LYv1_P?($8Nc7rmkyU7>dP*(&A*&W`Q!2lF6BlUPWm60GWxwd zbl*!+Pw99AC5+e@X_J(vgdEetZqAh7NHM&=Sb?3JTZ+iy6z4c^?_Fc}3rMj28Vlc$ zH3XT85B@5d9Y0gToDon}WVywsXVhc`G1EGJknMePQ5&B1U4x+VG$*ipESiGhzUQP>D z)ayZH`j1ei9utjJO-Wp8ua2DLyj_EK=qETa|7Df#bK)*_P3+iDXb;A zOY~g>FZ{s+amp_|PLezZ8VG;(Rxso8SD8E|T|qVd+5Y#V`~3bS)p}?YJFwFD!Syn- z_c-)umqFEMXT}1r!>)Z&+G)%su^a)TRh-HsqNjT*E0;@kDl4`3MkzknUy?^Drd?aAXd(Ksn;naQVxd^xsbC%w}=g4tHzJgY}JalJhp18gQToGT_npY zX896WG&sdS{!<9E=(9(s_1U)mj2_BfKbXF0Ukf6c6%l@19oUbB7}2gMkkbctt&Hi? z2lfiR8KVqkBQTtk-pM7~o4RcH|-%?zX9!ckl)Wb zg7-oeByNBr3pZT4Q?jrt`Ta@{*OX%M8)-_xzMh-k@9tk|3UT{eeNl)^e!qV_Kqn=? z-+C^x^y}XKRv{d=bJCj`p<}N8C2h|n_A5^J;cCc{!<1U8#kpHRAfD6}m2V-mL1tr# z;Xy*yl-7n0hJs-pRHh_~Y%j|2q9S>rn$|}ZcbM}={;?7{9 zU!O=|BQX`xb{<%iKu@yu?B86LIco_*<-)6ZWG=j}lA3o2uYW)%w-2x9dAmFCxQHsF1-4ZKimF^TdC1zE5iqzg9pydkpn%8VaHO- z&5KMYl4fT!4q~DLsAk^s0r^dA|OC!v245rJaq(kBN^Mz+si zkhc#G{On>;uw8N}bqT3J&PAnI`-U#sDXEwuGKvb%%2#dj@Yok+x&@DYj7Tn!^W<#J z4hy~;j-#VY(-CuhnvQg{TccpB(?zm4j+%QlJsAhAdUG7=Gi`?}p$ih%@t;?VbK`v) z_zjHBg@4}u@9M~5lpj$2)%LS6%0Kx+Lp8HYv%jbaEn=GE=Hm=wN)k?}| zp-6aHC)DX!6}W%`#`0@mEHkfF9T+7@NYMU~P<7*{b>~_0B{Aeh9&Or$`JSw`i=j;u zf0n=2uHcERwHEo0blul)($~Mu`uX!FK}~P{G;ZvB{dkyS z%1k1n@@xhtWNUxrSb|kP{tvR}SBNR#7(?B3e{J<3Mi^Q&>8=ltaK6h z5E2E6?9@bsJW=%1mBzpv==V+uuM|6-%N=l@75U=XMQJNg_$kbdn(0@TS;iQ+F52=x zB&**=OLAjc;c}z#Yl?cLJ>v`_HiF&A!?bxLsykS1=CihpA8JOct=%%_A-LwU3H12y z_eA(m+f1o;Rhm?o+;?!_>3-d%zb@HwI{=S|iwUmM?ELEwN*W(VKaLv;-;1rj62(h? zU@ZK^h(nxulIN1T_xS$e(%gCv!DWQsK)d7dY< zJLhafLf4x1L6>o1jy>pfxfj2IfZTHqXFG8wGhWm7pCpg0ar$h1Pz86MU}>;?MNo#J z&MyuSaT7pR(wxMl2g(pPI`2a=!HmR3ptf^_C2&0G^GT}3PJSYHkX(Djt<3PVedtFt zmnib3GOxk=`3Rw++sta`{TSbN_obh2Ks`U6h>tXa1WgAL6bkd5wDhC@Dgg1s=bJyt z%p+Z5k3jC$l9cJp=dW0%X_E?QiEI7!VYNPk^@;~0dfnK74YI1%CC2Wb%M5}e8Zu?J z|0wjmh*-Q#)`hFg__AC@FKNLMu}E6*kM|Qs=T(`Oi`NQC-kP5le7f3@UzQRV%19+Q zu?V*ZYnKsaMJY_k>c08Wk7U(sZ$I5Crs}!-=?pZ*+vi8yU->rs>E(bwv$jV6YdiaC z+S4^Fz9>ik$Sh^``*O%8-`e%)w7bJqTa3mV&?TbXDrzm<<49$`ES6vUh3qS_C+G={ z>JC=DXxuiRLdXkF3R_*ypE<#VeIJRU%d6w(yw3h!+u4gNdR`b!v3eEn?v@V=gT8N& z1Q(m}ncH&Z%Y>yLSOp_{$6>0_xK-55 zGK%r7H76C8jc+SFsqC_!T!E>BKY4_zK10vEdMCS|(D5?2-jF?34=Hj%_HgpRal8zG|E&|N_ljSC?~y7F3b5uX`-{5z@7?|GpSpeNu0aG!ke6RsxSp_X8X=` zL|M3G)w;=9Du!(IB2k}PP>s{;w^+$7@M^qeCk4+}H39}&pDOI?4#4m|sWcZ1XXSj- z8w^{5SzriI*xj6jU&=PETKEaxHtZRm1yQVlAqg)ddSLs3ZiPd!9)3dVm?|O~cqUx( zN&OwN>v07LjwLd}7NenRfS~9C?1ISrVNERyG|aDNn~NhzG_Cj)sv!PPIDeb77j>w8 za4VLH0cyZHGth0^@h|v3gm;kJ3yOI(u+Hz?4bMZ^nl6eX&n~0tqv4Q|XII0^j0wlj zEDN?xETiifK%QAHSucY2e579cD9sT8VO;$*-s6#dPp=wY5RO+D)se`Fo*~eA12}}N z_)Hn`*s8jL&Wq@gRqhPyfoby48ZMMHXw4r(s2WFlk($W<9&4A+`QuWCqPX_9Sf_f1 z^i=L^D>o<8{+nd7bh+McF^b$f7)>Fj|ClD8r3-+t7Qk{ct1lwBGr>YW&RgGM>qCrc z!&To9KljBz%Lf$sguBQ?f#|$~{&ihJ$y14;UTbUtb>cL|dOSudj>km-0F!or@CPpq z#e`_3Ji)49G^SLh9joBZv|^?5V{0v(6`Q5+s`Wcr+Ax;00x42S+hJ%fS$v6Aj=;*U ztvBts`q)xUai>1E;;Ldhu0FQnV{}zgV`%JC0zcti;&S2q8h+TcR|+H{Rd@sXjpCT} z(kNEnk;Q8OF7br}6iItZz?>j978Xsl=msW3+tu78N#r=fJ@M{FLUk9S^4+szwB@lh@ z)Y^e>CifDlE$W~VwF%Po*0BaYIOpWj+Hpqk-s5XYdUoCu5$bcvk(sto;1XRA}Q_Q&dP{q3C+qnO2 zBKdOHqwd0!SlR`;Q$_L<_aCkf1!~Gz5i;?j>NDf|drMt zE*R}*{2rNeXK~fGlzuD&l1Cgacsse?LGdmll4k%-+7ZVqGrCd`upn|Y&=TZprS0fS zK|Br^=*Qm^QmUjg>UFB*FYn?R7Mo82TGlvtfWb%h12cJMM#n;ENQDE~G;x8e8Urs% zR4bVbv57rX!uoR3GE+KPLf(1}#%KG^tQx&8a&VAv%BU%VoTI8*>prf0Ezw1{B>%!k z{K~T-%BNAD&~Fq7#3f%JB!xzN(zamLYjuOv>x760gVA@lX$Q!*R-t*x{R#C#1I5Ak z@JeDSZzH)Y|Gr8^&JNmBdO}rS;1@^|Q>hS@3~!deMMqvXa!( zHnmi^V^VM8iA<{C2pFZ-!ztevjFLBkQSyj4(v47D;jFXrVZp1|5_#jU8!J!>ib+`( z3*va6e4S_$JMg$m+E_zYOnM*({88RodDoJj>jUz1f&)Q&C(f52Rba(YRO5ut5k}hG zAr;?)mzC;R(0bij@Oy>jGU(_^>4C3qFnw9VRiX*9M-Y6smy#o49&Q}Y#jh$9_jXRB zaTQM!@*gL}OK*ns|6r1!yzCAF19$LJIhHYFoZ)CZqiSZLC@mPl0gWQcG8@xqS{6RD zH;HA+JWZbD94-64A`!J+%+|`)YT_I&S1nQ#r@*gCfm-o#%H_}j)sbvEASRbW2bOts zV9|*(Yw}ontmkv`ENHEBMs zUGX~#7bKX2OYYwJhd1|PEtcDpo%;j0z<0!?xA#bP{(ZwrA@LGKC?2kNE_X#TxxMJ! zEd`J~c}?H&XqZ>7Y{+)J3493*ayNnYx3&)zkB`cSR+_)q0sb_sRI7~W=otCfFIcrtx9}&c;7<@Cy<=9rT=%h9Nqh)4 z;a?y>@@$Ac74t-bJ(|`QbITh9{|m)QWmNUox)S}mdUG3F$ImE{_znLBqwjBXPt$E| z4&ck-+4vGn5%p^o9UFZh@(g&E)!Ty6ZK=q_uss{EvcK02r9{|1o=f)%W61Mm06h6vNjmZI_Q7?xjiiSOgbDgrc z0!|jO>~d_k*)+aa$AtJfQNNPWA1V6dPD&Y4&I*n{Oo3U(;kewH!kh?wR9Zhb37v3=K_i1K{qj|iWPz${$V^i-5Rdl`%m1HJ5Of&@elaQH0=Sd z#zs6m1^D9H?Ey;ZoVPkpE)=TTmN=UkcDBqGfys%@d`{bc;3v|UyvOOj4w3KUebO^G z#({#R*ZPm8^*uc}(vI^dso*mYlGvag!Ts2+dA9j#coisJq|4?j$n}m!e4g8fJVDhynO9D?!L~1x6p+{l{1;l{o zhU+WWpo2g|KzF59$l@}WkzF_ln`w$Oz|vyq&|?eftUQ< z-J8M|x{QjyaR<7PJvRNM*wLUa(J((lIquvY;d^32+o53xwg=1^$j(4qO=dMd0sRxv%=W0BWlf_4g zv22tExtM!9pPb!2|dtyZpd{$RJgIZQaJCA?b!wEg^!MHa-K(>cQc`Ra$L$ zrVD_~I45e|Ff_M$4%{-KKg72<`r6DdK^QB~=jcdBsGSq~=g;FZN$ z`$^V%otCzG{p58Q;>h$9_vs1j`Oz@K2FChl)=fy(rY_;=)_&#P9oCKfi&c<8rA=D*$>4F@=(b~ ztuns14p4b3-)8)-5FP~l(`($>XO;qeVmT(dpG>9F^ot`vRtO-rA22KoImM&W5{F#q zVZ0s{ZfzH%C`caQ`G;^Mw5E262VAtR%z1z+#emwZn(AOg9bMoQME*fm&2BDx3R?Fl zh>*z0%yfz=I)F|kn;^ItY!aBDELBqOy_Y$puVTZ54JIm`&(`Vbbf5GU|Lm$sP=>D1 zSWHGRIIlY-i{2=DZ=Y4_YDpZSP-=g8G9`Y zX5WCfVEWdVEmsR;JN8>^u&>F}mz`9%BYoMukAE9|+3_9OSXTII-}aJIoW<832Xii0{B$8DoXUUGG_~OhH z9nL1Rzx2I++^P@MY#7y-WK~c6$G>Dzb(T@@^Gf!5%%TUKeba-F|Fm25U=c&gz~e$X zlWg*snB0?WnF^|ReUYdzl_%^kp`GM87!$G&A5SeI`dV zU(gDItGer$0 zsRPAqN(p8WVl+z`3)O>Fi)9mD<-e5&w$i+SzU)A?#mNJOAxrZkB#sJ?Y8R#a{ghRq z{zTSA%>-f(7|)xj*qR$j54e2NHj(%rj>vwJ7XBZ`<&>`xs`e88598ss=zAlQdvTGL z{i7ME8TcAXfQENZ#dT%iI@NCE_T=G833I4)HSYgr+Z1K%LQu*ZLX>GGcwGW_S5 zJpQv`+@4|$qS?<)(h}_F4SOZ;bomMCA0$Qh4Z(C>IpnGWBpJhy%bj^6#C~dZlIFXG zy=N{m{IqL}#&Ga?cu^2gGY+dgCN=eRnn`T~*wq{cOpC+NX+<)d;`j~E{_xr~{&%KI zZeGJ(x()0u;{L5O13}l z_=9PwlA*LF#InNexY$b)4=X>f13`vNd&tI@>DmtL{qG|r9E2gu-v74a@{gSP!3ZL| zV((x6pG{1{R%gjWX**qTIM?3)!D|FaTb*A$A{$J;M^MJz|C4J4+SvQQ&&77_{U<)0 zk+tSp^r`(EhB`YMm0hy@7*5677&~&JXRQYkInLM0X0TOh`JL#0G(Hduj==h}O4)84 zpl`CJsC)-M`-wofWTsmiVwt)>s~0EHS$qS@3E&*1`kkh)dcM8tEgOil@@j#?ydFdzzuhEQxY0S_ z_h8{faeC*mys}xPV^2|0G7i2nCYvPg?_>Q_AqZ$C zn}C=6HMsgG|5HDbfz@DVj6pyJ@+t1l!{pbsEiQ9L|4%Bl%(qAm+LBUiY{{O~WZXTG zV8z};)vWLxm)x$1Cn0d#oP_{EvT)dE5sk8NqcQ;?8s(bg3~l(n z5YnNYNARl?|EaWPbMknPq95~Ty@DsQG$76MrwLfnp=Gs7HSYhVdYlzn)(uEmh{McK zfsi7%xdAC(KUN(X#6CFR-yH#zh7}Po5L>Lm?aR4hlaClKsfdV@uLqGhE#s*EJakLt zAv9RX9a!#*pE}`j1782HeO&cl9*VQ`kd>Fp0MJUNW4XS?lN~v|DA|@sR3Nq^elKVa z+mCbw3)_Ujeb%PrV@NvuMTVd9@YA)Ro+h$q=Zyz7I+ux=ZZ7}OM|sD2D7$<%HfQ29 z!@+>9;0wDc7^^NY#g3m{6q5M3B3oydZ*dOY=qr#t>vkzAE>?C?swL9TDzb;HDer}i zk7r?{AmUsQk-Zpfsjvs7T8!AQsm54-W+^X?*b-HzrVa-qk1CiUz$Q%PdL~hD_H)KT zm^R@pL>XZ1vsDG?CTZ2ROzV$9-(x;gL*{DE_D@p zb!B?jD8YuTL0YK&;2~v)%EjFh-UWsd6oOD_0#gbU0grEG)2D4p@}iI6LOy&&J966 zk8w6`)g@p*!x9gGkZAHiqyl1onu!Terh@n(zb4M3(TM7Wp%Wm+h<>R$U00Ua%7yL! z^`Uu#=@sw5XdKViYA{D|AtBVXD;J{^oq9IQ1#ZUuXDbBcd_dL|;kTF+xKJoNk-ePO zU$SmoPp)I+YYDBWM~jtFdbEjQyi;+IJx?e#`ma>y@e$IakDR^Jk*|?2)W~ny1EP;3 zM466X^XaqMr-T<)5IjV9ZO8ur`-6moAXPxs2oTXO`JVj5hn&EVvm7s1Q1nQyHBWpw z1~!RH#mdG?I7_Ef%|puMrsU|46^G|Mc%OInyA*DFgyOcBT_S6AtFx5GbYO8{Nz4*~4$`IdJp|b;PpkH+*D$HXC4n*FDW)d@@JfUkefea1DKkfKO>An^GYbAPD=*b}}?c?b~l zx0GdfvD(ln1QlK7oO!=s4s6eaOaOa4KqOMz3BOk4vk7T$bCjgzgcKq|l%N?4PA$gd zp%md;lv{<6PS7s>#Fqp@#_}_SwImlE z=`Cg9!@e1@J-P7MR_Pn@bAwj1vrZ92(y2E-2CM=E>O;$47(hk{E=$#p_I@`S>!|>u zY%HHp8ttLHn8PVjHJFPGz0Iyd@v#ymI1F0o_{azrdk-V=)x9!yf9OC(1WuNp_>hVG zIFI8_%9|f>?Pe0`$3jhr1A%}NqSOVbnlKmaU}6qb%ySJ+aq%JZ;d!x!95Y#dYCBep zWPHx}QBaL1xWa`72KI~h)NDIf-vi;0Nw$b4b2W(yjv_>Lyde?+~1?1GIi4RiiqV%O6^{L?yphx2UOt4>~!QetEb?w^j4)K z@0FiBn{rNpEQRdKK*K4%x)V+N`{L#mCP4voq2$i{r2#DDWh8;r8&CKensn! zhe0NbmPm~7mk1wqD)kF8-{{bKx)dqZkv&L=)Vh()hzdHbk;p}G8NW4={{~-+-DyLWcv8^-F@{@YOQ-X`LGj~ zK4$-^d_gnPu^hCN?l`h(Mb49eGiUwNS1`jUCt$*{{>rEO71VwIrzdJ@))UZOEzKG! za*Z*XJ!ICi_``JcgiBU$GKS49N)Fm&6pt_FTCtEoMx}kImh;RdTI-hQ|Fu+M94m$VEVjlsPI45bo{zpJVZE&%n{={Nk`J z`V=VD5p;{KW&FnpPMb8mnHUvmZ)Nrp~8}W(P&iK zEW5T#&Xi7KtyE`S3u(le2#q3bmC;tYYU^;_)*fD44YU<r$#$oH#RLND#(kwXoqj7&?Qx*N-m&t29p!5Rpz!!=s17=TF3%*q{=}2H?>*!HvnqE6VtS!(-F0d}>pdA=okl z;;jW66Sk%og{?ZypS8u@Q%?eE8m4d9o;p{)tp-hKWs9hMr5t*oF zDPHo*o3ePfIyG^9zF;1xhkrzM4vjrkZR_Sa0-aK^Q@6LnLYs80CpPn-h&&Wn)#t*tcJr)_BYswaQzLYn# zir#3Cgs=~ag&d`nyc4EMC7LfXxCWW0g%n@3Y(+VrIo~683RLR-4SJv#-R=#P$lY#m z?<>#N1H5#x9^jeQIJT%4Lj?C$OUQ^lgS(2#U63W7IXyAIgquT+KVf6Cw3Oo+X^HE)g?x(^_Yv`;0lVV3mv<2MmnFbPg+Qk77PI&siaFSSS~A;)Oayjtf~8D9Av&`gFoJoieyWZC{w| zE_|_pZv^hh>Cr;BqqgeMv#pvb-k0eYw&EwMCmJc=G!;@l!XudVEjqTxJqKx~m;P=? zU)`xgdJsv}5-6w5;V#0%N?_=ztd+W;*Lm30F~Qb&`frR|z@?MDy1=Cl^xt`vW!aO+ zNWbHJEITbdA1BHxRr66yz0T$nwm%*a55vP`{MnNedbxQETJKrZ4-xTq1& zS9xG8SlPg@G3u#V{NUj?lON5Spz)0t%R_smSiIQ&_lEZ3D()5eP(xEQ|07PEJ-ncR z-Mv2>?>BlXh ztdu_*zn*KWSZhmV)LSeIUM_9_RE4*(DLgT)drW>pR&0{owJGtTD%KX8?tOt*c!Y%* z@8E?qvr_6%-E1ouBNbvX&WBxG$_IEok5O=4DL$`e{2uu#(6mHevF-iyJ$8)PU|_~O zRb!xOv3mEVvzm8ytU$ptUMN3S{0b_u<5J#=U&FPf1)MerD>1W0EqV`o601Mb~O1l*CO%f%s{EiT3Ps1A#S% zeDMH_>qmtPO<#Met2K5tr4SIQPeWsWFOaFbKk?uEaOAE5Owm+-qw#+3x$@V|YE>?O zd0!Z@>F~wLD_dh%Np%hJ%lN6zt<{JHq|AiY*pRUrL)*x{IEnRzrd|d#0ih{D5o#c=pCy7#6t)3uC zY``Hek?9YG8?_5@Q!5#$p?8bv>qvEl?Q2FNi6y$_6GYifMQ&1u=|IEHr>6MoY^gX3 zFVk4Czf9{kGHV%&{y<$ieZZ=pyWG<0W%3%;WD8ua#Q8c?&Ti3^Vchl#Xsb-l((+aO z4F*v8?+@l1G3si=e`4YwA}Ig>$ll3=YY3Ki2@k>quT!Ucu<{&YUqyOxW}oO@YCNyzzC*;8?UW z^pN$y7=;HH(j?=S5vS+l3_EmCL1w@q-&%LTK?cQuL%vn+fP)GUC8j_jXTZPhO0XApV|4mGq^eX<4Av+v3&T!cpFj%(+axp7f6G&AWg=P5qnV&o82-P#9>-YT>v1rZS>xE=bTSS- zqiP)A07~HHIUpIa2mF9Leir7R3G(;lzI!b5E{T`8hAD(;OlV*yi){Co(5m3V|H!;# zCc(o+g>wlOpIUi>qiXSalaQXYZ&{v6cI%cU9kP;U%j&XF-qm+|h4fbramrmrV-NHf-~q4?3&5^URYog*!-#R9*KwwchAUBk zjbXEjLWit|aVk7`hVe$T$O0Y(7r!u`BRv0x&>=0x^1`8rC9PxCM`w434p~*R=;)y} zi>}$Xm@wry?*@e>V9-NHq78@)Wu!L`?L;y&w2Q{Y|6kZ2V_3~lhk-V5&EaHd4^8S2 zGRvC@M~=;RYx@T?jIUaRXA?z92<>*DfzxC5%j&9 z10%0Ur?9KP|n0!i8`W7v5pmoLO3cZZ1gVtBPu0=`5rPEG6_OO z5-X{p*Td^5XO3*p0w4^$QElyvAi z;gJcaj1Tci_z=$zSu3S$;;c?68$nTzjoLy0p|G_D#i6n25#UVx0CYi5+~%^wVF9h> zQg!6%Q0s1I?B5WPMz~N4EL7yeCv(se{s9dwSVcbyO zlM7R(Gl&=dXNkx6<|aPT_0Q<8JB)~EvO&+KkJ-JL7_&ayqUVP!vpiCWozc!BOji1Cceq78BU2wU+c0hv2LQ%rlGJwkTP9=kn1 z2ltvEji2)cDjt4{UNH?nH9z0FJ$HU!Y1I6@0RbXr#~wSFpC>8extwoQ)5Fw|N9tE} zD)CEsX)F0d;%pMAATD_Dw*Cdq_kEZioGvI9A99yGI6@v&sRufUUA9ccxnmtm*^Y^W z72?_+E|pjdjfU_Viw}2i>ak!8my3Doiio6udTLW1f1>#k2kbz^ z1gtlDQ}RtM(u{bx%z3eldnshkD;ISslRv}w9Fn)7#wh0hLPHW;S9Q<5h)h)eRLMTS zNDKLTf+XuLN!~3FRwI(B8w#PTDp`L|x&oZ8f6vzh zn76)SPzs61DneSfx9%#2*_wu~lykeS&|UYku&uDDTE+r`5?-Dp+xmO^sSX93O{5dV z7M)ms4i59p<}+|x^0$EHX8f|Hs3o$uioY@#Kse{7d$$V+n_)AdHjme(jGJ*BqVY04qZ zs+~y%vu0S6>!sWBN=4?5E#i`tm(?uu#pg&Lqkq3Ds4+Gx%XjKea@DvSOXtIwqQ> z0*`@swbS@7Jj*D)r-72jC>7TXM2vTF%dhg1e3tGM1fDDBe=|O>i7pfAO~+e|KXxxq zlgFSTD?8n)Iiif}2Jr8!V%VJpp(E5!;StIq_<^Mqg|LXlfoyIcG3`ooHqjjWfK*fW zjMA7&I{|?>32ch^Uu*cBABrE9twxkw9GzF~tD6m8n`kyBq2ZX4JPF81Ee-Rk+1z4W zWxp0A#qaL;yRs3q@aNpc!$r;`FS4#}Eg~k!p=n)EOtSUTZ2L@K;&C}+w8fgJS^l_e z=0|$SWjjCe*RWE_Own0#LyF6z*tO*g=Av2sJRl|AQfl%+3G|g!tLqLy3djOuS(Mx~ zURH2iK6Fk!jIV9|b-eI?s7+YjT7z4;ycv`v_(N+E*K^(LVxa9U^VU++QY{J+tM;|u ztLGN05Qy5PQ*^OQeq7gB$*Fs%+(TUG8s?c?$4iz3RM$GVa~?c2Q`}zJsR|5U?zR1@ zA~CNrr>~ar3RgbSvA0oAdN(qF~+E1t@pAmVfse#uGD+j4s4`!6o zKVx~rA9y^ny4l%-2&trpc&T1$v7huyYpt;XeYSmBR@(?P^+b=B57k7+=P(M@Zo|qw zr2->{-GSFQ5vim{akgu%Y~6c~90ycph3Zpf5$e^XwL#Kd$}Zz+mt3~R9Db-kF1z`W zKgi8ASJ~ox_yNqEJ#`v%Y9{;VEKfNr5!66(qw%nTd|u0G$`-G+eixzr8?jqiO+2{5<<9K}7N++>u^&teAp0chQx?fapSt zRiS}7h2FZILeLCe$?C%-f#?tK7AosU~?piYPIys9D>j;s&NW4sai`KF{Wn8{> zELtS$_cXV36`~O0I`a2uRbky8ZN6hD2!l}7ZQQdeIz}E9$fHH;1Q@>d9ld%@ebpjg z-L1b$-VV{eejc3T?I3s}g}764o{rKurIl{j`zS*aLavfG7Kmy=9dpr1yRad;ur;Bm zbwaT(apwCR-Z%GJ$`{M&F`roQJ*ZSuRHQD_OqLJ`#9s^4UJ826Jz3C;(5nOmkfOLG zfWZENv(!qM$(-IR6~XdQs-8DzsSolO2IrVADW?~OOknP!SmrH?j(25IkW<^9s?4m_ z0ryblkMb~IRc>w=cLI|?R3z@dC(3fg z$q})tEqRdFpm;dw9jxv%sh%^+++T~y=BSp)K7)Bwj%#dt&YCmKY2hd8U|5GrC!FPgySM;8W2Q! zJK$o{>!uGTxd-vCvGCUbH?5sjYl5f&A`@XDS(9hB)m}q)3M6E%6=uG|)0_#cThrCB z#+vD{$SCM=89zoN#yo;NvT~_qsrrd8lb<&AP-^2+UI>10DKF(u;*zv{%)-E|Ua6O9 zq)RB!A&Y&`&n&RqK5^zEcJc}ix+d+1?FBul^@^GxrVgZtBz%tu zlg;>elu~@niJX(Qum!|=u=G27JY-vmo>e4#twGkh&D5lFh9t)sabtp_;g_yTitCSl zuADONbv=LTAS7;A5)rQD>~Gy8^9ueeR_Mlqq%PS_T`n>J_j8?C2rj^B+-(~oZ9gTz zZQVm?uE19X(E1f)&5+8U+{RsRvVB8Np`b35Pqxp=DKt_FX`)LXT3)JdeDWd0$sS?xoyq#30O5(piEN9G;O=|RB z{;n5e9D(oTh1Z?_-iR=OrDZsD|MAC5A2^mxEE2QRgb4FlVuE|#SAUt~!a*z{^jBY3 z=6sbZxzTHwjZVHzJWf4YcnF~>=ZPy@^eV0QDxI6I^p;Gehx2uEyjM2ll@)EK?!fuE zWm6?Rj8$O)9jKr=@;f*7gHam8bwQoPPFIM{_lUyfnZ!IuPK%43U*ui+%bpCU_3z=P|p`QNd zJG9@ha=OCb!vTyimKrd^X{Ji*07V8cr_B?rxl&5+?(}_!{-sJjr`=rqU3s(gMEd>z z0{-q~ziU{jAP_kRcr~Tr)vVxE>-98fA?VnTUMfKC@@($r_W8fEU#019MqV?Yj%TKQ z(oNj;me*X6Q|Krul;-$%O|y{50TMp@qFy~4^(}gq(Z7Cs z{*SX%DO2Taz3^M?D*p@g6RfYMeNGbUbgobJ96kl9oEK4{?H&ox_6^?!pY!^YC0rmG zge!uOpbtv?;H-MZ<(+n!NGbjNcbG2`6hDf4`0k?d|I&HEJFncxF>tr0W>?$aG5@25 zH_z?4`+I1PzpjLDo1!A{AbHQr7oy|4E75XGV(gCMyAL@Mb@hW?N*IunE`*PF9^bv~ z4PqeOJhI#u*;{;nD{vM$It|koCIVnQxdUr5!M<{pN>cB@iSs;>P`sZ1D@e8 z#0nSz)*J1?g{=k1gNZ0yyb!4v+vW#bFqRsR8149weR)MqJdnb0__;NKL)vH5SmR5B zTQ-~#AC67z`-Ntz-L%G+Vas@MN0|RQP5Ywh0fYviD)ofj;Cw| zV$LxQa=03X1bGt9pwp22Pf2LmQFtadv+&Bn3A#%xF6t;yk2kcVbo(NA!Vj(IU|qE{ zTh%)mz!86!dN}MJ^Kwr98i*XJLS}_G$M&&c*ZQU$DI4Kw%HR<7hXE z!<|O)WXC9;+G7m6xTwMyc3x2#2|0_DZ2%rxts#p#te+H7_cZ0-Xv>u2sBc|TMqr(C zbQ8>^=`6)GH%X}4d9%nE3qhKBefGrvV3yDyV4ySYQ5n<%X|AeNvVlvbfl|PYD3;Wf zRGK6G5+u!%&W(Eu=^XP9_Qd_$4iQ;oZ5eY`E}h}GLMVc3=TJKatB8L|88NxK^B+o# zxAXIHdB#h1W`Xj5uGjw0v$X%S3`p~uwe|QvU-zy3pT)lACUlF7g}CMndWZa8x_=U4 z^o+f}{r8gNH67uq-i>fI%jsPGP2VAHYJhMb{7kw(JD=rV1J&_Az=*$#N~(|j!+hpG z{fB*K`&@UYIA8^kRu3>0LW9Vvo(Y*(PpcgQ1qXlxC30x9g*hDOkciTn9#p)cw}Hdlg=PP(6x z7|!X3-VWu)M^Paa$>p`q`M%r62-OAwlU9ihh&ZLOl#+ATik)T>h|;u!Y&D4riYdNd&+2>;bq>&OfzPf}mEaC%Xh zROOC^{Xh3g1ci;x{saruQCRV4wQ9Q*S>nR+HjknU^|YMAHgU9UZ(8rPuXHqC^1UK? zLgd*CBue)dp;Zp_y~{a!1{(WjNYWw-;$WXegr0`sNK##RDJ47bmF{$&sOnd+xpt{9 zLd2o4Y!FRO^8#kzQ5qdFSo9sq^!CWLAJv_^vy7%|ajqH^KcfgW^zuxcDzr!X>nku+27*zjJCB@ z?=Qy-!A{GcQze70PVX+Asgi&En7e4JY$KgwP#$ed%^?SsY=rYVV0#^|yD-^p=enC~ zrdBP{JpeP>Pg0V&#KI%I(*V)(R7eUK|rOIM^yPRyzk{@$%fR;Gk@$Qr*(f3xzNP@hET zamRpZyIc~i&>8l;m}>xuv4E7oCzxV(pbd=bJa%ddt6|et2n5qQ4wBbR zdrprjF4?^_3jdJ&NW*5gNYu&Xv+3EEd<7f_*mkesl+#x0GS51cjA{21;wX^s7Rc8z zbj-KZ26Tjmc%hDBd1O|X=-Azan1-jg=&fr51eqU7~b!Tc7STF!!;a}-)X?&d3 zTODt`O!gfKLUWAtuUz3~^hTZj>3~$peF72}Kmxr>NP{xn?MzvQa_VZ+@k5r#;!B?- zYK!$T;$;@|h9QcK;^|h~rOD{$f7w16Jv$_O$s2jZ)bKf*SiZLZ$^1`4y@=_F9x)Yq zP>}q7Yb{?#_J)|g%e~w|1<$)n;%#npUa6IMn-fcYL5UB5HM~bn8BBNy;d~^}3LyjR zx+T!KrI5a5(QW-AmsW3X^+gWayvi53L1-U|St2FR%NB%1m(zAGgm7=O<+C!0tiR6r z;x1o-dP(C2Od0FV1gwV3iq|riRn>ppFkNL2jcOA}@ zpHlsk(AlT=P;|DNA5CX3VaM(>-e+e0cr>@v@lTd{Wp9gsi0zxAT;8T_11(55W>Y}g(A zoIYjO@N??a?~0#ijQuwFxdDbss_LX+BgAm5LAL>#Cukrr4#n*6t^y zbN@Sp58dG0Sq&dbWK*Kk3gs;DC})x46Rj^(`?4J^1vQsJ)tc0Xa~AyKVTyFlfRZ%} z)f^-Q^A~4=GB8N%oc23>7*^7f&`HSd=J4l0Ww;EPR2wb#m^19x%ekixR^T_KvtS=3z zpP}hcljK$C=2O}cevM`GV4PX_bvA;n?HzLZ#;+ejDs2D$y}sKu{MxW*E`B{@Yy7G_ zWheLr+1o|@Iw|Dg*G$6C<>A)_9E5g)U#&;?!mstSUHm#e)n5)fSM#IsYXfI&Iqd8h zzitbEd;GfX+#TXW7JfZ;(yrjw8L#cIzBGOvH>DSTDR}?_E#$>%S@;XCES#)2l1~=* zI7!f0wIhAQF+))UjBZ!wR!<&&riuczEBb~XzOc*sh8JN*otN7fum1Do8QaMx8=oq( z&^kVKLa;GrQM+);S324jGardyAx;W>9WBDLDzoOq9oSC)k!=5`PRr38zAvsa$ z;C%fktNHU;ncz5PWqWW%Ci>57!Dlm}e_l49_hSB-m57sSMwW-Hi$_+3AZf^f2*E3` zGljxU5^MfpH6y2pf`TPKC5mO4W(HcG%GuRbw~-R zW@R06K-bXc%eY5& z94OTv9>$Z-LszuvD8_UNH3bef2zlORb+|V`(D)FMZ{v&hGS>VsCG2yC?IYnXL>O=)t zVA^J>SxJVndd6H7Bvynyg`bzrmbM|GO04T!iiys_#BB)rzT)Aqpl?m6@}E2}k%*2U zOR!}%U@!p>A69~SMd48$wbtrD^!))r!Z|VAIhUI@AT|LlxtHx(#fwk3hwo)ljSoqF zE9kY=to$prQ=c>mdTlg$*3t@k$#c-Fg}Hpyq#MyyMN~O^CCxJ(vzR98wTc4KuL@`H zH_;xvmyV}hqrx!NRBf2Ock*aJSX);teNV_nm<0B zAE(+&P?!!QsuMdS0_%!|6kBCa1R3P8n-)(gAqh`h(Nq_rsqP3$O%k5WMo=P}mTiQm z&XD7d^A$NR7eee+TCw|;{voH*n{=gPvMU7!yW7+Aaw(7BA59KXL#WseMn}wYBt{j^depp9lg{CkcRt&BeRoq|=JWjXdCvFJCOgQaYUk3NHnZiiV#Tn- z{*T@5@)ND9?DOBobJ@QcamoH=@Z+)?mA+q-DCZZa z=!66n#&SNfR&9Q*XsyVe1{Jldv;vayP`2XD)dcnD*aiKp<``Bo9Suf6t?2Bu1Rw6@ zMa=io67an{ratl!1noTpZ%1%IBf+XZRJNLjpVCVu(!)0K3xd-wR z8zfZ00SW@?#0JIri4B6$)}nOAf|JwzheaV50?x{>imDeEieu0PaNC@nvEWvzJ6_Uw zmX|*u{ndTDnXTY7s*rhtT%N}S!5^)>RN6CQoG*|)Gix#pye`=%-I|-N;CoUcUUD+t zCTaGHzLTw>m^W#R5^-{wX;(JfSX4mpZ`*%=6J`eMx|o5wgDRVE-D6xoB+4R{B_O33 zsVMR6Dd&>N<7^}mjQ!2li9TQQb+^X83k}IUXMTujy zzY*h(|PtOV?Yc~Em#qyFE6Fd}oiOQK{FIF7u<)k0~z_jiv<<5vbDEv#j#P64-WjDY>IZs~9 zu4zZ*T8<~gXX6Z^{u)B|v+~)j`Z;rP6_qPECr_64G^7X%j)g-akN|A&q7FOtIix(m#<=n`Q{_~UXZ zmm-}~Cz|Lo?s(YjKIF39EgG_ZC*mS{6tX5@sd6fN4G@)@0?(B3rt&rC^CJP09Tft} zbjp{ZQ8-*v{I-YH6sgusD=Mynrv0pH%~q|M)`a@X%|u+zlShZG7i$|ojg)$m7dlqz zepW`FXrh)C1zZtJ<%)5N?=u>oqam0yPj?&go*%a#9}*FrBnj2hdYgit%mkN$IqK4i z7T~38#-k1V7^5my6fg*?_j}b`uc~hgMn7%{TBp?qqn|efni}|)j1*@4F6oyu-;z{R zs*fQ{VI@t-lu5&_3eA1p!PEfbj@c53fiStt)kC9L^~orxlw2Xi?3eqA;NziHxWw!W=tirV;xi>zWCYNn;jz zWbk^rxLYK5;QiebE_H#!0}nVE43Vz!UeHV)a=DaC`7@)>3WiYgv)%mAa5Fzfa^@4eJ7CQu9!POSSka|wKZzf;z(G73n<7uGXQ!#3=uBN^7C-X0q5%A0%?}&n zl9cBq+8&!)^xX#23Sjb-Xb8n0ktt^BGgYQ_#!Sg>cWX@Yn*2N1@&O2C3!uZkR{&2Z zS7J3QG?B}YuSG?ce_|#zge<#U|5We;=JZHmix2?v?swJdCu-`SNxFzkxC}z>fV2LY zCO>V_dVb_Dh|V2i#+W6+A(zEm+WwdRlrM{pFKVDT^&n-X><`O>_E{YI>;kDT-k|z! zMeBJPAnnSOMbU?(IEcj)`K#QF!XZQi1M99LKB3RufG=6<$!po{DW&ps>jG6>M&q9` zwhYDV%UCxu>>6YFq|!jcm;H>!d*zK?jc`G4a>Z)FN*TL){~FsIUTcN~Tz)0+n4|);i%5noWpHQio}uF%x3s zTX0Mwj`d{59Oo+W8$71;)ewPF6 z5qK4Q8^$n1X=<*ivs2#LYPz}W(s$B#?h2@^X3-Nx3TN#K_4C9S`5_`yOx(^Jo|Wlk z)jud-*ik_WGdc#IFN7gI0|XKYdLXSh#mC8F;d2(t2czA^a)mO4Z8f7GF~W%~me1S` z682ga$z>9sExY2XV(A;4Eh{tHBz>d#cEdnT^peLxLWoxTVJ+T|Qij5u#7u@mLY^M7F|2hDh709}47!e;k!VgmqV zSvSyaVQ2VhoiLq$Gkk%jq3RtNQIbSo5W~2QyZY=i+8*OMdS`Gm*)Hhyx z7=9ldvffFR99HWx>{1ymq)IkoeHOI72*xKMeD+5lCa=HQLShUl+7_+msr+#%VUmitG=@DfiOa-u)Y4FORNaLV;WgT`S2$js zntiU4w7wOtYBw4smi$D7r~?DhRULuomjjL4-r-Tm>RiFN3d}9<)mrO~#j6M(Z~VNu z@iim%0g*;&2{f2 zZc~*%Awy`LFb%1x8lfDg<0YS<@o=6#L@X?eko>DcCIS#kGoh}`G}a72<@?CKnuEIl z#DdoqNFnq|4iwdhzO&SM6YT+n$T|2DMNDKyM5Hp5kBjQiCnTI21PSnrOMn$ji2SI0 zVd2=Pq@|@!}Pe5WLqC`av-byN3 zq99qw;x6n0q9|GstQNJ^7L#2-5!|>N%#-a>da>Gzm)6?aw{Kf&D@DAJfF^*qfLc+j z22gyKMGbf%fU^JZnR%YwO#rL?zdt^jeV%#da^}pLbIzPObHs3?7Y>>$8NT(DOP8_4{|gUoRLle=4B)Wfdq2 zFi%?`B}S#a9ow5Us6XB@0&FGB`fdB4XG2=K8XgO;|_Max?C2^ zGoem|tB@{85VEB!LbCR_+e#BH$b682_9gx3bwX+yV716wH4RWf0q zXBv;qd^84ns(FmaV}<7U6`KE7XlXF;axD25(Ucz{?FJO-Kk`F5&f-lN88Sk;$+)an ztxn>S@(rQBfaNJOmOV!(IM1qk=%L6~2IYc9Zr5sgr^i3!@()FJQ^*xGKh_yV8#VXH zUc?~DE5l0OIZocEVHc37EO!%y+xCf(jRRv4Km{3HLI|P_t2w23TB0y~xyT{H!oG6> z1R-QDw=~jBy7WAckVxDfvf^-|RmOBCMQDsJ6eHp~Rpx`(YY3R$I^jC@p06YJO8OJz zRdzYztYX$vkmPoL-{t%{h5rnFu z!4CKo;p6=yO_mEqNKhY*KPIr{KLc=04)nyEr9$?Jq<<5@r;j}#qhTqjkU0v-LLI_K zFW=}|kpw!__N|YH2`oxix7H8m87U@H4egCRIFRcC1%j0_Qmu(Qj*(Y2x6z|Te!~f5CO9^NP=HIiSv2h>idb)7;_e95Lc#Z)rsp?G5(%sm#3ISi zVQt||9?Im0ks1ttTl4LD3hGKLH>c*Pbuw&B3MEh54dXI9J@p-*Vf6|mek6)ZuAg;*h;?V`J^`j>uzhI-l-$s7E*0;C-*k9EiepZDu+uWH>d(37}wBvT{0lf)Y zF446LBjjz_+PiYI?Vr?Epm7x48SzzZ3V)dVCFKBDaEcneiN=##`Qs2!8#9rH<4TFu zhG}j9S8PHl|3ZdF18iEzuY@1#hT|kZbQh04Oi`5~HiR!c6&tZrcuJndAJHvT)go=x zkeYuN9hJQ@SrAIQ_I4Q|($r!pjpM=JRZe?3q4&-nmzfTFrEq2gQO??>OP|C>uEsqm zaTtDdULJbZADuK=2$e2UY41Z2W-9<1OexJetbx}s*eyHCebEy(|MbMqu}tgPhCx?32U&D>JL=va$UTVp$ohH+hEWfqSV zQ30GYG((C6;(wD0<(O13F@|7hf7?Rd%5>`39`;*!Oa(^DVAa9U&{e`B{deLQj;0%a zR}@6Hm|i~>bHNq|Dx+c5e${~vHR{oFs--)^&7s)}{U&^$w~_j-U4I*`-`4B5YW=oF zzxjAGR!*4RIN@q46E;EdX*$bt{Wep-b?LV`>TSY8vR$t~H0uxZcx#>TZT`Q;Wz9Z` zsa6EMh)z+Njp#yS&iNUPCXW8R;A>M8gMTNw^YH1m?!48r(w)i|;iGo|zRxN( zJHFN5%yW5TL_t0OJ#IQi9c(z=;+FY+D7}93<%ba($MSBrqr|Y32wm%W0^nT9ULIVRQ|3d`#ZKpPU0ry?%>qeOgA`Y*(j?Z4>@@ye5P?#$#;) zTbbSW$=7QWhzKmb{R@xb9L1Ay;3>V6j@i;e)srrrfnS|1+3j9b0{}upxRK4sa==;e z8^AdSa1;qA`k%5(`3(M(pVf?s%R1*lG7KgIRahkGvLofw`BHWtnCkXP2+S&%PjAV1 zCzR#bqwX)krAyhX2O0vSvZg01fzD?xx?a~SHdJf-0~DqLr(U_{q;M8+Sklh#o_2$zvGP{fz;$vS zXsF!4e#~e#>XHMpnZbuOMl$cGzEz~lG-y@@uX9uI#oefiC?%wbapL3q$@(I>>~YYY zttP?xR6hMfeNtMO`ow}F`2)Ez2eq0{)a?_B$;{qqYCQflQzzQfc8-3tr|tFn&7QXN z^jov8Y@vEnb27rCn$J;XSb$7)PJ1pd*KhV*zK1u3LE$!I0UVe49{w0=mp&6u&1awA zm0Q|J`l8z+KJz8vPg!E-V31Hd1ih}(xAhVt68C!_pz*WYY^{mVR; zdj3QEN zpH;ZbNb5UjPKU3t2mk-_^=oS=0%nj1vaIHPD>LBmP34@A`;Yi*6~lzyuCyvR&nfSR zul2tjABX14WqdL;U!mWM+d47V5iOM!{fHh!YFl(a5mZDv<+Fw)&T%`a;6$Fe9gMeFui04Mzo)8tO&A=$;6^|-$ zZrgq0f7hb^@$1O~M-!z0yP_Zp-ZxGCJg!h`v+#EzJ2nr6Z^3OU)X$$rs-Fi(^Mlo) zTK)Xp#}9yr)Y96&Kn4_=lPYaO*r*y+Gdf2-Is|vUeiPl05;<)2hy+ExFDFk$Bs(3f+z<9oi0Iozo8sL_(Ret;@@0c#fnl@Z|^y z{z~{YR@TkwZUlLxuQGzj_7KDu(WGbrDQ}Iuu?gp{j_hz(H`dKP{0M&~{$^u7fC1(3 zwS6`DW2{^>H?rtEuJCE#R{^hlF6FKgwS|~2y<)ms={hwt=(n{* z&5Y}g0MS?JUu4mBE+UEMn%Tm$crAgqD1CDw!!0jXAF&{#X4@Y^H}W#l$%V?T_RU57 zTXWd3Ke8T>G1~lsu4To<_q30k({89Sr>*eM074qJ8pJxCZ8y}I`uSJGe`L{Bu8@x^ zgxzHnYH?n$M(DcdfZ)srd9h0PaQLx5Lh7*UFU{}hScNqHL-BhOo703ND-LkHudY8) zC%uvFi4DqkAljj)N_jUPE|LPI6v@w5RmcDD4M>Mat1$fkqW^UZGa5e^TIdBs{6Xny z>ZqLhB-Tm#MF}L>OO5^q!dj2U&YpV5u~Lgc{~dA8(?P1lp#SLbT;=9aoMNI8=?iMx zHQJRB##o}?s`a-m`pw6ijZ9~$fU#G9)3Vs{f|{esDItA5k4XIgEQJ3%dC6Mqnrry8 z=-c2<8lfxFq^;m6KpbTys({QWGShUng|Ki|YDx>CaD_@$(~mcWF>QtS#?qlH>9GN(q6f+Lap-?ip3C#;)0hUE z$SS+t4RVE2W@IVUC#R4whji(d6V>3}BW=@1$)6dI2h^Jyfrojt1&(Ok?0YGVo35yL@<{cupE1{(6Yh~QH-cB~g!py*L%cx{$;Mph0$UcL23=s~)3njuKF5_dJOlm{G zAkX3PfB00IT9_Krjo)beyr^^ zxmpHdtbQAgWLVM%FzoZi@xMicTBAFW_LR8_9naK+HC06aEb+ zy-}r)&UH8^g^FbFTRy5sy$`3I`y-?{{&YXn)r78LliwfR5%J?o=f{^0?~e#&9iQ{v z)<7c11k7`N<`OocCze$6k?=co<)L<}Q)ddhg&rHzptvL!F_=mK?z(o9AxJFKT2FWhQLc_2Q<VZzH4Sw3Tb$Ta-RnEou5U@1 zD|9^Zv2rRDnowYK`tQZ#vZwn!`Usbf98vCzAH&p@&i16a#|50siS`6Fq)Y#hO=Ac2 z7Sept1L@GIbZ~M}n9AxP&pUE5*|-CQfDMA6QqGX&;O|8VES1Gy;;OP#AHiS6q4=8! ztqT6esqU6_ZzT&{U3}b^&>Xq;)^u+9i8o_OVgI)^>6j ztShsRt|OL(yH$Uou70GhK2!DR|ElUC-KyS&qX1A=+f`p_L+#U51De|7l^AOp>Q1%Q zpdcQ}qdqz;JOAr|(Lo60>ytU;>~TQH;*bDxyQi?3 zP;PG7+y3y9a4HZ#MJ|G~$%9jU0dq&{l_RdFRW-oN$T;Es)ijR4ioW zu*c^FvvwcguBOPm@U=?T)E9~D)(#8z$)cKQ;Q08DiumeVw~)~&`6bF>f%A{bVPS+UmR_}i@Lc7Y?VR-$r1s#SSQTy@KF{Hw5{J)x!6+F&+9;Xi zGe!iPjS=1z?4DN`E3e?t1*g@C+1WU2OU|hr;+;w_2uEpgH74C?oP|5bWuu8cFh`F6 z`&T(ZOZ<;zVwtRNefzCfx4r!8TmHw}ci!LfgUkE<-nZuDy0rNt|(1pfVYBza2C`^S3-*&bLy3 zWja{-iNEsAfP0^xh!sKiE`Q~V{+4G-f|c(|Ac>&+vw%Cvr-8xB_edv@#M9;e%I5=> zyZ!Yai_;Y)r4)tjlB!tcyOfk-ly*v1J}-s;f9D4>yKaF;otJANzQ#T}7pU55#CF4h z0+m|>@t_rqduAFXd5jW1oy&x!8v~ar$sw@>&7TVkF;>nV9RLKYnvrx^(u@nMoycpj#FuLwRYQ zt;#6}gl|W*?)Ihl#XR_BCo`rfgaOpMLY{~ZVTsA;LIeCooUUZS2Fp! z^GsrfwOg;KZU1aN;Z-EBu%1%MPKBYr`r-@dRwHmb%$@kxxM?^Ih3-z>mtjrn;c(}z z2~qB=Z;1en72GSU4mlIul@6T(tiGb~Fugl-Vooant4ivAQY9Di$%BUpi|hovuuzEd+1kr(_) zod#*paDWxJmM~X_~i>pbgvEWNAQ|OuWv5>^lEPD8$&{HWNFa*3NvLLSJ zpj|S)OuB6?$Ccwy`py|#9*M^y7gqS3fGqa1kjR2J7yQj#+1zQYm|74H`r?J(SeOYk zFhh<}Tv$jjfmy8=_HuWo@s`?`F10{p>VcsunCo9{#g z&mz(6R^TT-;IY0%r4oT*K1+z!3k%)Jn|SEC9@I$CdQ5`oCqI8Gi@9#oLCeF#;!LhI zs9I}84>IgN^I4^S;8*KQjQSb@nH^*XzE%*vUalFA7jB*J^gTQm;ca8|2N+Nm^ukRp zR0?ig4sLH7m?uO%xeRlg!tjIH(znNS^jg7Rf)`qmW$sY%Y>-=d7DTa=LUE2LIHLtk1hNc5ijC%RO{y zJg?q=TB_P45#OUrbnv*CFaQ!A`$)x$`88oh1d|h9XTr$ORcLq>=lY+nbi(8 zP$nL>dMvO8(-o(;>Czqjd9x1_Ba=9e@oDJXn(~iA1+5dyIrWvEQYO)wrj)F3FZEQ6 zNpnADw1vwiPoWmR4p0KI#2R-VlQMccMgVZ_1%qKhpUKu#yQFC5xw^NLZOP@@0Fw12K5UZj$N2hjfvJfRkKv#efoQM5D7&nzm+Y<*$A-C!Qe~St>nIMxRs8kVsPp4P)I7zEc4A4duBN`UrNo(g?L0<9!CGo zEswZrV{UZIeyDs?VS}_;0fnFTsx_o&!u|^DQX9lC*&d-svh8J9qk5Qen9+w;piPM= zWR2ylq9WGrn4Ie%7?jOQc2RwId&42ntJ&^gtAf(1`?J>B^q$Sn=>5@wRwRnTT!mUt zXO=5xa)M;EqC+oF3NN84JSmC{Uk2Zt$ygADGkJm1#g?G+|ExGec-RN#vNm@r2|w;j zOc`zclyeYHZQ(~qs<0wDNfY`Xb(Oigwjj`!FVS7|oRzkoLWGutw=i_UcBrg`=Pi(; z5&IFHgLYwq^JtC?iG}t(5vM8nmM+~XuxT}aOS<%l-n>QIgx=Sy`ypEO{SfQX16DCn zENbpx;x+S3Yu&=y9p-)riY#tE^nQqTIb$Ef>T#3PIMJ9p>G3?xSP;o)1BeWoozngO zjEoJ0DKZaw=>S}aNEo&-J|-d_!k#M?vtG}A4qFhFtGLgh`7rl6?35m7mN`%KVvV5o z)SeB!?B~pnKYqb~ywBl__74O;?j@1_vdOjBX-xXd`4(D}PG;Dn*0;G)MwTVE_CVaK zP8r3vh7NvMny@zUmlIIOZ7lnpd@x^#7ykRk9;+;33$*i*uaB*bq2ei3^V&7mtf{1w8RRox`m=6!%q2-@EPNa6dvD|r3LM11nmO| z+H&hjd~Gv=cFqmCf;M4FTRw9jQ8olJKj#IEmHj98CQ?)R9JI zJRr`5pX=Rdz?$>U=jq7e2Ut0NAT`<7DYQ1c&6~Tm;VtX~wE#x^6FRZ)){T^0p?)Ol&-rt;+Ed<;6IvHtG;Vr&& zxq!qRH%gX9c*K;}VU$d%HcBp?W|Ukt$0(UQ&lvHYc4NdW%xp7OU~YTBC^^e0nU75; z%z;T3pm8spq2U23U2zjN{8`m0Q_pOOkV=Lw9vdaY2(MH(hw7$LT{YE>G)g9pB)#01 zNJy5}hBx`^wq`Qu`Wc*p{<0!!YHJWw$-hhwu#YO`k==ByJl^Oa+I;e1l~L5lh85~D zgL)YWLA_4b`lO8#yVSjsB}f1L7N{F6sLOD*9eKQ=|0>drE66xsSuXbqOJgSO#-|8l zFAg82>4^>rJ+VGKT_jRsz2vi%6w!wL<(B(hL8G3VTzW*^FfWNMuLvOdb!O_^<&Hd; zMKsgoe#tze1Sx_P_5OV^CZZ9c+#iq+@TY)-?T_B$t3tHtS3%m z$yGspa1k+LNc(nGzH;lTcW_~ln?_bTgOHdq(kkPAMOkCB-IS(<@O`N#d)V*wda4_| zR4W6j)EC?a^=+dEx68=q&cE)kd_n*1DyUXXHO0H7XL{nnzVEnHPMQQ{-VWj)9^{~ZbEZjOqA+jce^?`y&eOm@VBP;cWsHM>df4N%zNjWI+C~l} z_A(1-UXqiCrT3u_p+&dJYQs zT7ex2)fs)P&AUq2TfA`7wLMjV#ELC^;hv5Teq;pCk^`xO^u2KX>t7?7z|-$b*X^6) zPCZ6?vD^Ca-%M}fn5Y0IXjNRnn?PZ{M?Q;|m0fMBi{Rwj6GP;ifJLc`ieQvT93pG) zDDb)2;heFGu@zksfwv*FnDgf)zWCVdK$92x^^o=L;d}oSm{F+6s&uM5+5Q}Uy{+zyI|G}?Di?c^a(B}x zCNQu!cf;|gqTV!HGgT|R^1S}VBe5sv1)_=|hX8kUoC{B)rEc#AF2W730)V78d(6Xqy zK64{MQzFo<5ahD$a#2X<)jbJ3x=M1O7#33u{ZihDv4wvcdqm*z@5|NC&sl8{;owyx zD(rD`qTd=~(d7H`m&taiHIUP&zW6d}8RGPrb=4tuLzc>{f+eZRyF zSTtH3WYo9Eb?tXc?Y^pi8BraA>rSba4cL{F;(<;Gbzi>G5Ea1U*_PdiO zY=v`BxZib*$)%GLgVpSjwwx;VaIH;}i+;pBd+y&Th9Ap&*OOcKJW+TxmB=7$mc*kJ zIueD~+KEq+h&4@v{D2KI&hOHN$ioWkiy4mzqlr3{6n7ms^F5DBg%hUv+9u2-2g5#L zP7%LqQGvf@V)8UAfZ;1^tm|Gq>U3`PKB^x{^)fce*$@v+obx4`NV;;HIMn{EXOMj? z*2Z_?yFHE3-Sh>A$W;{MZsifybk*AM9@E>L;PaUmj|9w<1R;)NmJD4Fee8(!Sx>Ok zc`k=v+VeBxCBU9VZu^S3?g0Xfi9$F4afrRWXMWt*eZKg`Bduval3+JbOgBgsgXh~{b#;T9!dX4Tho@Q z{)^Q&2hy+9|Hz`RyAbt+dsTWtwi<^^q8ZEtA&Vc8sZm2baH1ED)RUuT_~^{+s2N_Z zuUt5T?WD~7pc1WFR*TGy*OyT+5N|Bv$47ildE>f{5t}6J!LmKT5P3@Kc83@G6Mk5& z%Gnw1H=I)UNnw9nMHTd$8~r2pFJ#^J^Kz*qzO+I`)#^2()-DX&;g2yxkp78wS|B!7pkE|(-77bn1#)WL%XwL&vC8eeW z3A9SCls6DxmeYDLF)i&sVY8o5wUgro11HBX8At$NUp|J6py6LncnVK#e}Buqyg(J5 zReYBa=v6qwjHWhU;J8cdIH)^&c} zhTm!9Js)lOt2Qov%fHE@cIhKrl6%yMqkx~Rwfc1-b+ipG{*)|w+ZNXt^?jZ7OZ9y} zS8zzH_av`?7dX@)`>%T8m~%g9yzs*-x(UF3Exts(kVZtsZ*Yfy;ESVm;kgmNL#@Se z7T~qZh#jrE?!)c6NqwL}#F#{JN41YPM&vU$!?3MOAi$;wby7uz{16+Qn!sMkdC{>e*wbN>2sGz_KIFV`ULmsdb~RH{l$ zqV`4&(&q4}uMSe^c3tLt;Hr`y+8W5H}U8YT;)Wqm>rvujDRCY7 z4#ywt`+{XxKiFk;f%fIXRAlw8_;Jp68JsWZRY=_orI!8F^ui5#y&*N(`o};p8AVuFo8e=z=DeT+M4l#+exr z-PRS5Yb~Z1`O+JGC+wG4(?KS1Fdi6)x3BW{Ud?PoPg@&4Es(ej%INA07=Qbz@R2aL z|4bxe;1s!jhIi4_!u?X;tlaD~kCr6`w)%QSN3aK<`D0CMTs%?gBM=s}A^&{Q$AmUq zp>Kn9F};HI7Hc|JDgvz0Fq}b)7oLBK5OH|2FW$d`N9*GKvb@&ajW3t-fy8(swnQ|U z?Q#2xHyRClMD3t5@AZuShw`FZNRA@GdQp_GzhoyjPXcr05nw*%Ffbdjom7(D6_EWD zGziTe)Jol0=_zXU6y=R^hfnmySt{2n2sJ)5urenUmef@cBWJC%?h?p868}d2Oh@F_l9NK4l0TQyx%!*L^1Dlw@AQpmH}3d}JmU{y zwx%A8+-jT@zRn3r#1b?v@rcA$BJqfX0SN^=H%15I-{Ffe>4ofz3{lJ9+Cqt`*@cU- z!d=kp@T6aH-q@3{cukIG0?34F2ZugcO=SFqvGjcFv3yr*JN`A({~#&~E*!*V?6f|< zLTymn^dtITHoglwsQ4?({-*M=Z(RGjo!4r3dk29fI$1q`k0$C9PD5>2}4T` zcK$e3VTTk(OkzFeV8^Ghb42_w%0iWPxx?Y-5IbU`^yc5?yOK3r=pr~g+;~Q-T%nm5 z-x!2|Q4DKd_s!WhuO)TI3y+?vHKV^uOBqArzI-|G-YK9&EJWIv8wQ8LDeU1o~4T~T6+9djotL6Ztd6V z_J176Y2TUez*!--PTjY_@N$de^qO}m36`}q=JsuF69jOjf*G$K`lvZW>4!qBlPPTARS!$bX3 z6{`$8WB9mu;rAGPLM8S)89<=r0Jqgr2)b;BgZKLLbxOomrCC6|8IKP_Y+LJbmTrD@#JE>en{s;#prm0T-t ziT=HR!@6R;T+(aLho1J_$3#K)#qW}STaWQagr#V*Qm-sHdfHiC?JCsTn=XMqFHqi)o{q0oic3nI7oa)s;S!*Hp+uzFT(~k0;4*e|UB_&<@ z8!(@`N1S~li$8OPIcZqfIw=ePMuuNI_}aHv9HO)#-Qy?_s72TF}*pbE>~tSHI`J-0H8S zdgUVAQ@6zBXVp8bk)C}{dkl{NjmjUpoQEvFvi8cJ`lakX9bBGcmd$9bhnWwD(NCrP zQ->{|OMh9-c81zg4#4N*oaD<;fR+tdSpLp0avax{ck*_q)Th zPGZPr=4<*HU;I8@?kOqfG2ixV`p^@7Z=5HZ9v2=N{k+$LW0mfx<<&>a&9!ForJdtC}4h5o7l`dw-gFu6z1;i;2D+dM;5>C)ac2Hg4^{?h-RRzNS;7%psj=~ zfwnU4m{V~8&H<733Z|3u&0F0rWq$C*Czu@ed|nV#@wy2 z)H8eXp=ZdG=>Hbe!@3~|_v*&C>UW9cbF_mXC*aLjP@byA_$wDbiMmvWOzW-|+iWRqiXGe!nRurORan&7$piu}fd&(>YwuiEQ3KM5KaendK9m&V08bnY zp60K5(TE!Io(8HGBl;`qnvxh??62BvL~rM%x@k<`3FnO|3B4T153+x>cytP1$yqA` ziN%5m_Q0%R#gykrmvT)=u|x4*777?AoSJhf~rr+EXaKE6o_KE4BB+f)Yh&&D<^EcBS?Gp$ME1Qs|2!^3oR-(}UN28{+c;10eoy2;XYDu0;meP1>)o=AE2y{i zif$cOQ@p8oQ~f@dYoIGM1~3XzL-?D=$Kw6wrWEs{IlpCFKTq-I8go#5fig#>Wm<3?W2QLyX%J1gZ{2H#rACqHc@JVn$as`>WbQC7#F_nHSaWTRM|Z(-cumjM(D> za1$2|Y>I7OSnS8Bp8P3EVi_7H!^16>+r~xqzde68otc)WMr0c!vN^J^v-TSD7f7!B zXh-jsjzNrw9?H$0;dyUxBpa!c1lwdThMSsZSgV0978eMwKF~*&&a9n=(TQVB0YBbQ8^k|@# z@EBYlFslOQ%|&<%nVu5UQ^rrZ>8T)&^jv0`oC$kEa>j!ghc@6z&5NkJ=(<$0Tm#Q8|xESaAUxM zK1TF!z~SG!4O4xMxy55FU&pNv%x`b(4Wr>R220}kf6T9JtHn}0R5w*s8!KOhpbNR6 zDRqsXS;ET1T{y_dImdyV;e3h$DZeBS%j6u)lMWb8(oPkI=5vEIdg3FHbY z9O2S6O(#HoFFR0iN9YGxz-|DrcNJhjehaO<+Y2UHQ~aSP@>yr?7=X>s0QH{0T)aQi zD#Fl+eI(X1Ku@6-!}J?m*DY6!(2e*{t%f{4KeVY{62q-hKu;i<%q!% z$jD~6rM5k55{^`zrNdxxF-)=N?`N?E%MidbP`r$lZ=`OawaB(%#>)M4bGjQC;pcD9 zuc4oO|E{oW>7%Fk6~*U;`CpMAeXn=Rd+_;p`L=zWw>ae~KH#Z8peA@s))9X014$jr zUy&2VuX>0yQ^H$*%X{$q&%I`f9?@|9wzt#6T+y{_qWgzH8=gd6d-PzRYU3xZp2VWJ zqiKUj?w#FRUG!@B7*FD2dVb1+<9tuuP7_P`-)B7due$zh@PxkRZ5omk?*~tGT|ET& zS?%em_qf^%fnLodVWKSLYQ%Z$`dnY`GiRb?`$jT>M(d#YI#)T%YSEv%*@^))wVJtb z+uhmq&Z?iQ_0GUws0GQ2)nh4SJ?nRKZRPWUs+|Gj!kw72HX;P%My-?=gMXs%LG`-H z`nP_)jaTM|^*paAIf1H;6x&F?pQ?Nttbg!!UPU2oaCgDo7#e`*w*OW)!EwM8m`oeZpvnK z*Y#qZoD)5d3vj})gaGjv*g-%WV-m5u3EV;0UM>DKSIj}8arMmxzq!^g3flehIX-E0 zAYMB&n7EkzuNm#W_^suE_;=a7o6b1B5g73jmLDtVUdzW0ltvO~bOp_|!4W&5US76v zBSbF=y&xOpi#z4+k_Nbxi#y!<4G0(yzF0RsSoLAhOrmwZh%q1?G-{p?pmA2cT{qQl z?hm-zd{u2mGzPBxiCf(hnuZ=d;k==PLvIE0XKV?W)7iz}hqP5{k;(__J|S1`8-i7@8Bs3MbI}nu6l+9>^FoUQ#-BIz zXw*`4XQ@`AC`et>Sg3cvoHRNJz%Xpl4Z++UM?>UEHc3Dq;Jh2Tb%?}&ZNr%v(fdcz zYnrB))tK$0+>PVQ7!sB~;~9ZT(-gNCg;nmli$-~X+oKOIVLfpb5{lIk-rsU3=|=0R zYKU2Oke(+Sk_&B1TCeLzkpXL#^i4iVx}ol7LtzcSQ`wySi^_><+k^_}0B0oI#*ZeE z@|fG&wOjPi0C{R$8LsPT+1e}G0Y_e6Q@q7vwxrHR zO4FTGegM_DYHh%n*e+O4T`Lm8Q~Z(Fd@W@_q{-gGOOO)Y;@1E!G%$eKjsMv$2F1*U z@oMetsoxjjt@ij;f_~m)=cTqp9#}oDLaRX6+2RVgn?Z||XDOiSkV;tGUEht@1l=ux zs`c=J_3(lCKZJd3NcG|QI`ynhIp6D1++?i&8ZckC;-qAci*w{hs&?EV#eGN460&#$ znD~^kbc;EqtbX4o08o1_do|^XacBNGAI#=WT zz?=eDEh#8YLE3m#QSv-eqFv*SSea^gf)U%S9{ff_1Hb`64emP6AUW-x_N9QFhCj&x zVF+!0gElp*v?kM@}h)uym#MV+-64LCB+9lo$>R?@D| zh>fK^hQh%VtNy7}53r;8i8cET@1|rid2B3POxMAJ+Cvt&Pf!>BWl7@#1)J)kHw)YA z44UMgLT(#}Z6rkw1ViUIJre}ERcV@VS-mMOIFt*fD2j$$gNh|72}!ZhA%)tP2|84h zn=jXezAIUXY-hx-;)#>y$x9`r8#aDGGhoALz_Zzv#Ry*AiNXL?Qu8_d#NO~3qJU>G zWZgs~b|*L};^15eX?GS+ccZP@nPefUfg$Pu;vFlm_-gHuA=$|wc_J4;*SEq%bgp`G z1>;L#0g}ra4V-?}mv2f%Eva&&#A_KP!7h$RwR1G8+192SmdbZ=vF_{ThSJZV*UTs@ zw~$2XvP6bP|1eXdYMd%`DRUbZzFx|RyZ1eiO7anTQ!Rg>C~!6$NqMe4B&0>)J8k^U z0cQo^Jm8DlwcV;~d%&7RZ8kFGJdZkXCw%5|$sqer(|AA{MqDRx3vb{UR>+d_^T>t16^g{KcpAo#rh1NQ{=YXB1u^=G|lQv9;>R=q&kU;>s2mB5Pz!6 zR5jhiKK3*Z5rnT&tH?dFa!8c^M%BY`yR84A9zmr2dL%wJ& zQLNgs|Nbqm1ja1WJDvM?YepA{^HjQ7E*+=a zSWfp@zeDgvB*0{ocX#7N_X>Ba4*^Kg8)W;Xu@@DqZDhtAi#tJ;idFfEMX9Z=2+`Dw zHd?XI-Z;G6Z*E4zMr9sf;>X5!v_GCFT3tBY593{sR+*xyyvnqEtP946OlT^|FK8dr zr*5wOolE;h(rM3QvfUiO#8HJ#njcx$9yw6F0F7N$;@`V<-Z{p~msrBro|$@0Y$2U> zM@@)~$)BBR2pg^088ln4UDg%{%}Kzsd;VY?$Y7>IpD-8Y?L2Fp6z(g9F}!T%0~ytO zU*6a9&efUu)dYL~hUonM^hUqgA|}8wV$Do%pkUtY@CPbIRML_3x6hi}M<(x+HaHb5 zN>-bvMB3!Hj2~Ajs$~hc9U^XhQ44qH)o> z_1AO6_OUrIqTTP_jOR%JtP8I@9x4x*oaVrn$h-*B^pqt_WpX77W2zR+?g8^P;rKUF z1(i2+uUwXH)A`cVjDNk7Z2vnv0`PeF`pxHU!A;{F^>+Y{N}S^G_G-sr&Q(t#}iMO$n#8*7T!YO_jcp0aEluPqxLXxE)J z;CXSmx76SdUVlJ3X~b@(YvavJ?tR#wUWW}o^o``TVA3BSJUpm+5H~pOE5e`* z#w_^BRh@n54m!f#v#1Qd_`TBqK;n#ij2`z2^QG5YNLnT4VxHT1_B`d|gK{+>Wj!gZ z@Y3r%kMZR4JmRk0{M2xs7$KZj0wX#-Pw?gn7!x|=erSL6Wd!(VLskv36F*(c6%2u@ zlo1p3Hsw=Qs(+2y<>3STl(fXO`y=~@)RrWswMXP?@d#-JJ28)e`+(Wb95W&}lIC|e z8+Tm8L$K=Q1t+f-AOa({5!z|EkRba?d$&E}hANHvYd}!4ikujKj5|h3k;FA_ww@|C z`}clK77!vGV@j%}qugjG72t7bG`Qu1HqRTek7=aFTz`%W`v-FU!}M5J(T8c{j(?Nx zj~=+)xKk!78|nV4m*x*1AFsLhcjL_(8LMagRnIQ?0DF~Q39+!G-WoHIco<}$7d>pm z#;9hS@dVuas9+wKejC8GEbbXMfBk*|vv!2agVRj11U<)>c$5uo zA#0&wPrR4epxy<>{nmpBW{>#{Ej~=Vi30XXKMUQLdJW;W=?)HSMd*!n{n@Y>O2YUwk09Yvw7r;wedt7t``U{HMz+n^VJ;(#_?-$41sRa2aBAWd9Csdes}EV&L-` z<2U(=+Ygm{x0J(f%V|zH)Qwyw3`qS5uU(#zXxOLdpX0t&%QGvJXB(6iEv>C+4d-T< zp&g7XN3vEC(?9ghfT+>&xh27>&+0D5OmJ#25gHB;o?n9Qc=s?6#W3CK=mJ-XqabW2 zqkRQI^A%u5O9X*UGZHy2l$;wB=WMMa97Dz;l~MeftjT;*`Ys+i*%g^m6uZ^2E`mXoF~mzx1nCzRcq|eaB$bvJ(aD_o%*Y+pqtl3yN)*QJWn+8ME_4$gR^T2 z>S5OD4yQI*vEQI-gCtzmU!2;Eh8soxuq^v0LnRK%%rQ+gj`7%rZ^v-vqChYaU`tDWS=kE@zRG+MQG&1c(?wUsjl+I+j) zdht@g+&ZYZz5-K1(~a8=d9etqhW~}uPgbnZ5x-8Ct%J+ z6A~Lk2XVNxZNowD`2tF!^fXG?HkX_H;H_Li$UzxgU~pO7ZEZ&@w>wm-zGbJ+*{UqK za9Nd@9-ia>bXIt!pve^#dYOn3-co7i?hrT3XH;7pO2)y1)d z>!=LZihx{h9={Y7W^G-c$e0p#ZGS}r{^*D9+6)gnMccyIC9uZW4Xa~U@m|7nJ-Ky^ zGFBFpM7t)3ych(-r})yC=r3hJ)ZqSxnj?$5T;Yj0m?%=cg{3g|J5Z8lWryULBe4R| zu^U9mNnMsHF9Ry7OX>|fiCENCO|$kn_!--sHZHqO(MB~tnl`NHg@UnA=^7b3@khSU{C*8VZoFLCP{^)@83c00PgUDe(l z^NMBvlQmY|s#a{eEDVo!KFT^UrR6pI%#Ci28f5}_^U?hiCy zs%aX<8SQDX_e)D^umpQhps)pFt5$-ln=!b2kx-ZtR2NUoQ0-}mgdAa&EaRR1z!$uU zi~j0~?yp!-%WQlQ%kGC}U)*Aw06F=F>I8F(6RG>E26S?0lPAt{t9wdxk0 zKZ>#Pv5XJ{$55$^jT`H666Hp4+{RHHu$D|$&FqISv!R#abW53b_uMK-;WHvCEAX_J z3ZJjM1q%wDXBEA+?pXT#9S(~*eTJ}W`%-Wu)J*8wZY-ik)vJ)O1AD05-6yk6C_dXl z^|nQ{Ih%{UJ8X>ydoZ25RbGMA`dK#b_sC1-Uc5P`aYD zs!t|lI{cipnANP3a(J&Ns*cGb;qh$ApD0+f^*^NQR|vf;o9jl&#W;MX?bRZAFPEh5 zl9w|FB6+XLRxzC_guS1UZN-Qw{XbE<7sFBVE{gO7bY8anpezVUa%M)rzeAh8UzQd% z2PHhE-dd?U1@`G|szkdkHe!SUWcT0_BlZH>qg^wMnD`snbIFLU6BH*(XD-NKR?b5u zf0xC)5xYSOBuZzgJUr8Nt^5)@@+NZk?8EqYmaS z4DhAT4AgnL%?nkV@K0B&$!SQy-bo8~hhMK&V6PH1zKL~Cx*1!fWD*Fu?5xOTV0Ju`=sLj;03#YbnH6i4~vf}>ug~%A}GYqP7cA#Z5!Z-3(zEi)}-vw zGzDUmt7vA(SE&r8?`Gl5?chfoC~BIOq21HF>o0dxh(1R6vGtdA1Ijtf zVD*zEG%w(ws{ zE>JR#ABDhf7XAg~|56ryana8j+|Bi3Z^ynbr_bexZ`Hr*ruk7=nub>T(l}k?0gzav zbU))K>v$CHub1{^dOM7mJTN<49yB)wN35;IyR|vgo3*OD`L=TGc*?PFT2ZQRYqeO; z8AHLJZ8?V%4ZfNF$RROn$S`FpQ72~Vcdd|onO%Qo?TgCkTc#9B*~A|R7G|wb`Zw-m zomRgGKPNzs9D`Sjh7sU!Z}&l;dtc>dOvxyOIQcbeC%*4_%@({f#EI{6eC4*|xzU9OF|QuwlZTxd z_pvR?o;-@wi)=@}tr(TJVpPUzj8)lNyw+R1(QCFl&U{;O=6lU+u6LaIwqi_P?@dfw zL+-ad(Y1ZN2_H7>F1*zyY(y_CsT;0Li5)PHje+8|zN+0u!!%)x^-l}pl>QHsPV^6c zAKETCK4XNM;jzt7KG|-mR=VTOAQi6%wb{dozKKPvPo<9Zv~o2qjRwwJ39>Ct%$*oI z+f#o~`^8O_V$p;BZoj|_Yq^-mVk6c-NuK(IFP39!SAIWv2MMxDmTFJb)q0+i3RtuJ zMMW$od;ic-{+=3|&fhD;Wvb|vsY&X0WNN(ntxcULO~ZC;`_;e(7HKKCM4s2E*&nK{ zF~eP+mGvtk937G7L#bZs_f*c*$-3L+S((R?rhf)qa|x=f9e09O4G!;&RR<&eK@CAi zBL^c3df|6-$;oVCD#*bF%w>u{{dBNuSM3|R1Ib+?e9YHGt#87}ir;JJToczIIqJ)y z^Cs_}nx^nahqN}jvo?`>!e&H0j*;u;*c2#NW57h|mVs@79xiJOWf|is}h`C^r zzC^fLk@pg_MG#ZGni~IfXvwi0ASdy6Z7#G^OE|7oxmh)Bd zS}~g84LHzMyBi-2ad&>6Q4hp#FIR5cBgyBF8-7gA&fr$BRDL#G&jcVu-!q2=%mvhc z9ra&m*MFg1|2}Js)UPUz?Ne0Ps#-H*1ilkGIyGG8)Nj)yWYe0Ivm|`&cl$9ydS~%i z9J-djeZt>R@hef)$y(el7OJ{|gO4^*ou0q#@v>r zsxvLSONy$DhWEj>KmClq>P?*MAgRS&x@RQW0VH`@IUqjk<8pGZ5Pg_sVnBO7MIyd3NL7`$#^{Lho5 z4o9C(PV3YhkrCZhu6B^MT_|)pdLCAw#5tD%Pvxh?s=`1wxu~vJWK5AaG*PrcqOeUC zOoawz%|Pb>GE;pR^O{BF^@QZ$Z6HFBYblW>;eVzESALorpo}dkcCwLglS{BYVRs>v zQ8aVbG-J)^2Gm?8DzGGuTl-xxQUTRepqlDHRn5ho?pnaoBVvOkHn987QDBv!(gD_{ z4-{C4>)ry>W2us-m=G=-o~b2NtZ-az%Yhy|!&Yx>``K?PDy(0e7!t>o02pGlIgmK{ zcm*T$VK3pZq7`99BNV8%qK$C0_HqY(+pJGV37kd)Veygj;@k8y0Mm-7xDws}HYUZz z7HX$D=}!)9r&>+@giq>n3nSJ{nT&&i?LXu=lbnr!x9;1jmzvK?<@T~>h=u|RycLzg zy3vN@Vb#7^2!6$l1_GNz7D=7#6dUS!QgEl&D1z#fUKZlsrQ>Ww1<1~RkLdG!*68@Pkw^+(4*EOpdQ#FjHNQ1)GPG;4W>TNqBdMn=# zu*=rd!!mPg9P8 zGf_3RqiU?{<4II2Rl{RU!1vKG?v%Uc$TZ&VNz7Xl-SKv`6?J5$%2Q)ZcnjzB5`%MH zE?09$fv0HQ!deb>oKe$=6t5<+wOZydiddT$4LgJAM0g9w*Uu1r{Xzv0SYM02D zpO6W`+@Ymt5ECk=_i!7=mVv$M zNr_Q|d~ycs4%s@_Z>cI03@Z8!5g*tlRJg|H)ng)SFr`63n3gmP^<7mzn9K<{-4F}A(0`7l>??{T@o5ySx zR|e#YRzJsRB~hgkg*DWQ zJbXZ^z&b59KxbPPw2Q^MOjhDd`zr*bPX?jtvbZVX=-7I<@nmzfb7+f|7f4heBq~!8 z0U~OKV}kXVn>{T%2QwS{F%2)oz+KaL$?#>mm8uC#{c8>zA9>SEm)_ruEZl9NNj2 z?a%Xg>Za-ev~Db>^@4&c!>u(1y%rh&tQkIFt~mGL4$_P6)iiqgw0in-!_dk!7 zy|`55G4s!r{PoubCGEZu8!_;e8_``Oc&}O?IoK(h`8$L|g3);mShY@vMw*I}KQlka z-eZSZaJCLOtNQ{cF`WH3%l#f==X`g#NV%vLrB33eWC1cDZeQ`KIFZ-y`xp|OKUAVR zr?~t{)~d33n`2X4XO#5DRZdL)Y%`>GL|X2T>^o+`amLCPE)-?0_1X_1)%alW+5bL# zSbgz=q9Y7|b{zzJYPfn)fq1F&vnBL4vfx*WbB%5YJAgU5&fghA<5?>eWjmLd*bv_$K z2Sc35NQ+XX4c9r^OUD^Xd--X4Vud~h4%eT_CZ<2#hS+;LxjL6~zBorHeU7L#;-h&Pi0-h}jCR=-miw@;wLLT+ zpvpr%G&}HJ?z9~-MMzb>b*S~`dk%cGR{H9k94yZv5>ImH=|a6485nOYSmZCrMBY32 zIn;Qwn*2G&8ykYGd!|&H-6bo}Rdz3Pl-=)A^C8M^&5@K{A1$KZ9*44P&3v<`s{7cN zR&@_yFdFSwb*JwKU(xg(&Y^GdV~@>NNV$ohL+SgW(`@=S8ajm%^`yj)J=KlIFQ@Qf zX#6@#SEiFIrKCL)wHeC(v7{zS2djZols!7zSd@>Ny1z!%LfsROMBRCm0*jaAQunQd z%(kgJb-vKg2~aMf1X5=?aENjIUIn$H=_g_5RANY8*}f#NkaUmj+-|P2%3h=OqCe^m zvX+=qRChzStriY+@8WB;YpM}@NIgssUq~x#J2!31Y}U&f)PYQPyKB3P}H6l6w& z9D~&5rc*Ad_sO$+RDYQzBTynGc2{J^Oa<)IV24cS>hm zjucbA$|)gB)Js(fwUcW)?Sw0Bx=`Gk;EozB$v*xn0ShaG%eo?y9sgm4*b3H15?OS& z^&q;HHbU=!q4lAV?b%V4@$6vX==O$!PUWySrFXDma8og3_rFrn6;uRIYf0T_XSB~a zBuW!kW)ReVD)Q@1a@uYpviEy~@UCnS6*16WubG&=PUFT_@XtDJYy&kB=K+TDo#sH| z%s15QIXCblE~DL7Yl1-Wx5%CXvf)mY&Y*zh1r%8nJ)~%EwvK|saU=}8M*C^@Ld@Ks8swxMttY2ATD)|MZt-ksK`jw~ zM?C~pm;Hv$O_@dD+oT?85%_g3ZMJ^#l3vg46fo?zX-7sSK-91A+3lz`A|tZ^@v7OY z(I6o(lE;DZp!r_V+!ZuG@tX(fLuPbq)B@k;D2dWR_=%-X&CTpIcx?BKo2~$-yH8g1 zaJ_7T;``gs95D9=%@2a+yIhW=R78?gvT!Q~+Q?TqtMMri&@F7{Jr zX?{y4TX1gU@jnaB+rVmlTMoK~Yep`2 zc)fM!aE0kLGV|Ve0~o39U|Z_1PWH34*gcUF6zt2-p<;J}sv}fHOG!DK0?l<3z3Jqv zo>Q}a7YitAbo|+$m2k4+g^^OT=3+G830%I>UN;DyF@s%HIWRGy#Iw?!$``8-Un==B zIea?n5wA`_>|+$&l)CR-h%ReO9{U$BRu8&R`^!U zrj-0o%E(sFX!qPU52gW2aWq-hi?LVQIa(Z)1kSbApHI|WLWDF^rjsr_{1`@dT2FuE zdx#mOOm4VA?z5EFAp1O%{N~d#F!cP>&~`!v+4Gkg6Q%cE#>~7-)fyfroJ+O1n&txL zwxDTQ=ktwfVcrj=u$ZU0Un)^Lmr@Qp+h>)j;*iQpcXE{AHD?^gshmn;?mi(i4&tFM z+Lf0HL@DvnAgVy7porSo4HWAVkpMGYExmEchDPLswL8s2M$~4>s^LCkxhx_oRHX0k9?hc^auO zM$QSipT|ZYFu%_}=ZF3g>DuzZ`8Rbf7{W$qVcj)EE8iKV)9l7OtKv*(He`o?M4R8u$Fl-v zN9qks6&E25^bm8hgB|=|J9@Y57^JLQWG{Z(Q@qzxpB7tTxGb53A8}s3rud-8 z+?y(D+0n1~BLc%sN(>!_cLgTObf3DDBvL_4f^ki3x`b9E`#F{+1+f8eG$;8jr3P#b zj@WI)_K*x0HrBuvJB?-UNiMEMB$C*c@X$xx(XP{tSd>J4s3zKVrV$JAnz}Z!|8&n2 za^fs}u`k_5nL$A=j;IZQ25<)ip&!u6QNyCG1O(x%%os{x&-L$p$76m{Z7gr~yPx4y z!XtUraQTLEz5*Jo=$YhTd5-QISoaUH^1RPEhu+b(?NK>!vlp(9mGaLtOQepJxp_+B zj4R*|oQ(;;8f68K~Ycq!V)~* zBCZqKjo2%a5ZQmdj$}_IMr`Mi>NQ0W2BULyu#u zRL8EGZDXYzyQ<;T2NhwBZthc)m;{1kuY5gw<%Y3zIzueXbeO%(WOMXH=}8ymv)!Z) z?EaF5q?hyi&aFRx_HZNiH4+FK6Er^#j@acl-;$U$$dzik+AG>chT-t`XxCZcv!s}z zj<96v=My+8{V97-cKzp3T{4QSl;C6%Wdo^1_={K*YmN=0P z$#yij2qfy@S8_rP9AL_olWCjrJ?N}Go9qQ3S0eLhFN19OE1V1@{ME5!p_n)sYywrO zzPy9Ftwapk7~MZSR7yY<|A-9|t$|R=`-!jfZG|w-Jd2=|b5AlIqR%VZ7;`-HXDU$J zokgdIrP{r`gmeOY9p7vuHjq2{w&WAqAu8AsA~7xdKoSz+bTF~R%|R)%(?4R1KT$AD z@#5osRc{%wg$S|A&A4n2=jzHd;Z@qhdy?0X__yJr8BACzOxcKCL`ophZ-{f+(qKrG zPFsUAY3Ny*=0gnXJ;t&mMQT5^d^C)6KM)g^J@jCc}&RH1;FSt z2RSa4ml}~-v>Yvpg)Sn#w=w`^PDti7!VJ~v0}1nnSWkPJdXM4TL-OtaPJCsRqAI&m=Q#ggvm(8@wBv+)_(Xe zZEr1Y^@@KAMlCu4Oagi}sD+5P8l~zPhiVjspvb(xwf8ym5wN|z_rA~bK5rhFbI$(W zYp=cb+H0@9mU%fx4(|tQUca64WhC$WcY;IiJwgeM=Er~>ZzYivlwqdl!00Jatf2AK z%`yKd1Ah;Bu!lD7;+aeX*Ttor1YXLdkf7-jv?VM-4R+8)=Mc0;1&Ps3Nfo+;pp_~J z?G+U?LxPs8pdn5wDeE2;)JL_F@gfNthfqhW+NXO6=Rz=nSCKB(hvf`GVlZdAnTC+DH*T+I@Ub8$ zmZijkpfXA~1tWkGoHO0R{L|4}{eyP;Y^CC>F#MqmO9^e;#g)>R{bJIKP_Rdgwe0kn z=eEc8V@a?LJ)obWmHsJ)rK-0!ibD4#Oj93f^`i=KTn|6kn?4a3f$m)Wl#wGzkM5F; zUKHKu^+s$z>)FHf(w553>Wl6FYV_LlPo?9H(@u$A#567C5b7X1gTBEb4rk_{1MM3B zk{H|18Vhebo86<)U|tUG)u)H1uPe4+PPI5YtXgm(K1@JrY_x0))rI>aQ!?#R(x3%T zN+~IF4=48(`P_`#*`i6 z|L|&Kn4DILG>pBGGNE^yn9$=C`ot&15^vrVL!RR^Jy%}<-zK_nYFF|egD0l0O2BLe z8Fzn`k3t2k>3siRH%br#eJxe+?Qaq2Xi1Ftu84*zWs*F33YA+Pl(k6nJtvK`CBj0e zvW)fHeL`$AkCA4XlLkpSdm?JLmJ>_N1Nj)8&ysrxo}Zq_zO7S7PZMFVD!so?JOufk2>g90FL=84E7(hQH{_43i&(=XK7>l`C2pZ*j z%KOa|=mAa+HiYp_IKkV`JIXM053R*n>7 zjyqh_dm|7_FX7UF+KUh;DNXP5V2`i$2!UWft+uA!oeI_uk7<-=hrb?aS(YA8<9N6W zUFB|w^jJ)zdv?4(z-4@I0Sj@`vxoQV*Z?WJ`@v*csB1d(RG0Do@TC>&L{yjlRcA(~acnmx|fi8%(f2oJsfnV}qEvyw^X?*HS;y-y)vf zDo>I+lAe=!;QkC}jg}mMsitzXOfGgV70xL+t6F4Tzm&Mgq3ct|xEBs@xOZyn-v_vS z2Ve9xW=oCOFd|TI(FSej)14Q9uu!;#+u5DsltfA2ViLDm3%mM;w^&4r|zvhL&DHp zBXciLDTMRl@I;=3R}Q&OW^1@S=?#+bI;yfgBvfn*Bokc%GZvB*xuQGW>r>dF~=rp)vz zFa+3qklJG`oi11~(fSz_jF*%&^mu7NvT4%X=*>4r2g5@=Rsud{hCJpLj^P>dB1FSQ zP(#B1x0djPU}8tsyEBfqhxRp$W2uLm-L8AX6VRQ-Sf1d=nKa*MZWeCx@zN=!#&T$4 zHq}$=d#2RoE`f5q{6TsyLCMM*QZ}(*Vz&L58y~!E&x&N_hxX#pRBaa?2=p zb(8Fx>SY3n81&O{qSv3;FQ)4urvtLyN9j0z1JeUw2s8wZ_#XO~*eO8VMmYQ6T+FM) z#4$#_kxN#*a>La0d;(>&u~pP*G`9E@wkYqi2-Hk(6|!FIj~@uSRL-VQ%eWQKH6B1@ z`EM2<2kD`rSj=3Ho#1Nsm66Ur7817#5=R|Bpj0RvjF7)eC>=p3qXWX~>L4it1Xh2$ zT2h}44JH-4Ev_JROc%vrmxb3W;=-xSlI%sbVB(E#-(?}gp9)|vk785AO{DG+^+8f~ z6K2^>pgyn2U4_`%)gSf6)sJx>rh2+7)kDhM)*c#kcWRnX`<>%9&xf??^hMp3vJi1BwfZ;5azKNY!)sv1c(XOrU~Dl!C$ z;sh#p0OAi_`--w)OTX4w7?;b`!8U>_qj%!9AbGIkM_SE-Wv$q}zotE;HP1J$i-hDJMTH>OMjI$3$p{ zgHJLR*^UZlx&my^ZY-F3I}Z}{gW|FVC>AkAT! z`u~Z4!#e|P;&wq5?&U?)6SaTC;T*r?GupVlHg4?~1D|cF@)k zRslILyjLc#s9{t>L_}ap*Mep}U9;$#Xnu~z3iV6pBu5$Hnw|5#sz+^A=ue6zVrVxG zE8A4R(8_fMYF^MD9s;@-+#tYq^YXz&)pR8W2HcmHr!Qn?uTWD9vsSc3Y<-Hv^>)G! z_0?%KpFnt88N-%+Wk`m;fd+5c7$5v5#kBP;+K{- zOy#iG6&*ztYH`VWS5bP>U#-H8=2=XDcxut*Y=g!kTf5#^e>Srw6}e?fnGs7$s}nno z^+Ox?bJ*}G&26bD;g&+#!BoriGGo1%nSK_@gB-gd=ZJRIPvi*W-$|#0ofLRfq7C{3 z{IRxV-Gkp;Smv)nc;fI9uCmoYrVyM;KYkHeqo5w#{E%1 ztiVsFoR-q|v&-D+hxM#GLkZd7lwjPbRc4(lKkHx?y(j3hd##zf)U3OlZx{mSNZZ4}>%B^QyH*dYZn*Gk}|3C;l(3eJLR@uU)A+?#p2`usUz4coPl<$a=0he2Gw$fcw4a zT#{JQ>N(;>I`epH>Jg=YqW&-)%6CSKg0tQw5rIdPJ@cJZEkz2Ap~#HKk8aOW7=pb; z{^de$jwUvh1nOQkn!iO-qP2s zzFFp{8?Ma5f$R(Evt@R~+7G5D>6g#b<9Puvzv2OgzM{mS0%F1WkF3o-0MGO+k$p`< z4PBrko?<+tNM1X6%}uL#mkc2%TWS}i2GrW~hD#S0oL21ZdoY; zrdaQn723H4a;;J@8AL_Mf5ihui@&ekzhBz(Af2-(p7$K3oVcXd*E&Snl-Q4{04p>H z0k7$atB{5<)wHUHbnGT^11Uw4&iXQmaDCK=yP*AvjeB1z-zsKC%gQ@5n632p?ybjo z!-$+1+M69OB4U;SR|f6Ua{E5XQWYxTpp|3X%&U4)<5gUK{zvnR_GS00rQ27tKyvX# zESX2o3M8?+_?_}_@-B?dVm=NcqUn$l>+V>~CFaPaL^p+Bt(iYThO37`=)0u^a5J((#2K5Jsv0c*$P`H0 zck4KVC7A}VI^{Cr6%ucHKbl_L;KZi%M<8n+wu*k`4o(`9)2n>@Qq;kX!awlfhMxW| zGvXHCPk=j6snnFg^G`R{cQ%-1iF3@dHgVeRyQ%Y9IaL_D1qtgGro(Kk8sR6aBFF{( z1BwU5_5yaA+5o{yBzK+Bk)%w4;HRsU>l^CbEe(~Q;wVoG9BMl89R@S~nPBT!W%t+t z{+C5h2b8c4cAXzgY`7Ik|1M)W4w{lw@J+5eg|S>WEwN%3mOPd;p!vfN3{1F7_ta zchAclKoGfZqNFzCjq*su>(!U%+L^3FiFiN-J~)#{$efyePwZ!IxJ(6ZxLN`e>%`_Z z*W4GVh4Qyz51FdP4jRHKwbI(H2$_$ldJ~Tb5hsmoD(R=xt=gW5&y#{X;){65A2jsy zvxM<9B_d=hW6RcJE+hn!rX+%xH9szei4fo*0g297Pid?V20fwji#|%cg@`~>y*{8{ z5m3fgs8=PU#8`_dF(Rgn>m;U%j@?o2ihLyyyQ6B@{2?rxh-_7S*6T%nZ_8WkkX>BI z&3ZpacISjky~sBSM~}XWn*t*#A`mcFR!h~1m9;!_;9sf1zfyzW`}X+){*{H`KP=o6 zzwOn)?Bihryoz$=FO+!1B{g-#%Xw%>uaxB3E=Q7U#E+Hi2sT8@V8f(8q%`~vfz$>K zDGgo`N*T>-sTKKD)v!Qvod9rtSs=NTL#}rP26E)ZRwV0v;s*B3-zRBqw3=nP3ndl8 z2AlfaWHn;kK+XfsoV7YTbE4Di==J|wO^%1vTCQ4~^-jyT6R*F+Xs5{8g(ehBu9TkB zzlK)|!9XvKS3nDqv54;uY!LB9J5?2<&HdMlFsuk7%VdwqTJz=%d%q{6%R>Ar%BEK2 z5=t;V(K8gkTYwZmQBXkAmh-CQ3JM{ZU6)E~)0EU!DWw=goc&jS`O6;~r5HU;=!HTF zC5QyCZ9veOajG->X}Z%H>2B8B@twnoW7K+K@Av+D^*!@v0bb&e`Y!#S)|Ve8>7V+N z!MZ@zcXUC00toPX?*FiVUr^s0Pf7oNPj4J}>iQP`H|vXSSVVipFPy39r*+VG_*1Uj z^2mM%26e3PLe-++Vg!<&-#_mtEks9g_U^_@{XqD@u_EPB4_vK>RhwV1=3w0l-W%rp zFW9biF{?ctTt!0 zS`}|=kL>g8olD_*-n#lI5PR$Wl$@6d7&Ysv2o|cyLoe zPTJKG@8eZKCo{>b0%7?jq*a9`;(e6hAOE*!ASWp8{~d>@nvN|vO}58*{>HdR3}`u# z!=92d#86C_ORZ-j{}0ym25UYE*ICALQ6CJ}9e7%b3>wov4kXq|-Q0f*B=&@o>!iwH zVxNcs-?`R}a|PL@X1#Cmj(BebI|bA)qu%!A6wiiRR5Xh28-<@&AqOe(;! zvPfH%GD)zm+gQE{g)dMrWym7+ku??*-~pU-@X5l_$-}6McgdqBUQ5P6Y=asKSSGfT zoR!T?vpJV}cU_@4#7Ty6c%_(3lEy%CgA85Pdo55=lSnN=XI&#-QkKzlKj{*^@D2G~ za1c@UkLL*YU`Yd#>Qy?yt8fp!>NU3}N{nz1YD}OQDq7LU(P}-Eh46NkzSmW~Y9VBS zS@UgS8MZoMLhhv;?*oKqh8km4=YhcrdSI-#adV{9oOrMgOb~?Ni-ao0uz0z|;gdo= zoPGZ3lVuw!jIzBw-GI2kNri_K88+BP9`a|bH`N?4EeSW{x}JEK`tlr{q?VU%zS`ph zm?e_6*PamVaW^6E7PC~QkGW39)7RQ1tgO0+xaC3hmh}ecUkK?K!WvK?Y25~CgmLL! zCQxQL*jg}OuoV|%$I}sCMW^IXZn&B&YS;bBYQm6+Pn06e zxaubyv;Gn=kTRgZ1QdMDlrK0p9VieZ@M`@fP2=l&c_b^>wnJ5gkNp(dM*9SXlssQ` z^Q3Rl&7|kYDv_8PFe{&|;ZlTmxI`p8OLTr^)Eo_KnQ&w8)8T6(~o(DH@sb_RP5otHmeTX|J9 zmJNd)+8!UMMn}|16$xq;?lwn=Bs1HhlAwTWo#YqdA`i3TN?+^2fhv0P&r$5^XueWD ziE7#;WiYW+!sOJDw4Ixu(vNE0k46~5toIrLxFB2|>=asy^h4sa-XC72`T+^_agSq% z+J&5^#uB;m5+cde@&=g_DY;}9`y+A6B|{Oua~$;8mrLb1sEgMh>(^AfzlR#?RUwg^ zoDXWN2aplNoOz&#Br1F`>#YP>&8kCcFJ*ZpS$#lXGjlMh%6A%v%M8D%C_A7@+@2^pzuOg*NGp3jKd_vY^eWf*pc3 zUS=k60B!aYpY?uojz$~P>!V%=mfIof0#xuI#Tq7cAxLI|%>jAdhz<{k0wV4L$I#x( zq3tRHtCVv)%I=o0srO6e;4cOXDzDZJd5O+ty}!|e6hSa8xO=l|#P-<^?q+w0nS2NdU_3OHU0fFY6ACOr44q*rm|%N5;*H=Yrs@^k+(p5X;8gb49+_mA7A zC(Tpue~Zq}oIxc==t_11`mA@B)4UYfWcf*WCJ|4j$#y0oAaA5pkFHT!zT{*vRhG?U zF(ivvZrF*AA`2=5FMrbQ2WeO${tFu2;ZI3mK-Bissj!}QwvcZ2m}=NgT?i`%7an}s zSA>r_KDj}1XFC`aDOfG9d3ZOtKk(IkZY*O`0v&rczI%mQpw+mu2ulsq1Hy#&g9A#72;1ZttCck7ji9RTct2f$-{YKii0PKwL~?% zMKKn%MHQI!ad9LvBHc$$V{|=BwqiSs_`3u^-er}y%T{GNP)aHm;qbsR0W7&hHY{xH zM9DaQ3v4cTC9>X+E(hcA%q0cO2#Cj-kTA)&VR<@Gr)o6f*Yc6RhA5z}VK~1!{S@1Y ze(?2WpB4BvuOiCV`Z4u#lECWp=lf+utGZZyX!2#Wps*MA7+2flnZQPg0#J6l4Cx<} z{)&cV7|rS}+);%^?_^WcqloglF8xU9#~S@ur5`KwqftNR>BrUjF;hS4^`llls`aBv zKV14D17!@0aHk`WfYs_a6%(4v!%sn^U-9E?Uoa&v zU+g7lb9w>qHqPU%IH~x4EbIif?OKD)qSvCcjj?(^+q7AByG3%U#tp*CyYMvHe@}3w zoQJdu)~P)~^dT{ZVJcpK+ncQfgZf({|3)KRv@+{GnM;TE?uo^qZx-9GzDyihR1^lq zRx;?eW^Uqy@#5T~TG?{E99(*S#pB6iDAW32BMDB2cYWvt6>|Z(wW-6uNX$qQKQD<- zkOp+L$!J-dHv^?1bB)o>(kxmb`j^G}ahKUz0!B?_OuYj}v7n|vnDt)&jl$MXqV>jD zm2&fCMeq{(^3Tf$PBOTKn>-N`^{POGL|^%X2yH&576q}~u5Kd|y2IC7xZn9N)UWqs z+j^i*s*`$%Vorr>y`J@tD*Kn}Yuuoi?dVAk%BnhLK4iT{QJrsIEQHfuLjLU^Hz0RD z_|N+rTat3Yt-#!5z5hp~oLZ0!9?qjejcxA31NIB_DU0zXsW*4o`jLk(L=p6Vp}h_S zeogBRUt(Um=ICRp^Yy0PDM4`O^eM@GrrXL$-t1tPfDM z�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%0D#?PG}9){66%}-*IF7Wqg(xJXO9ErI)$&X`c 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 SBrEgjva>XI$mRbR|NRd`9W-wM literal 0 HcmV?d00001 diff --git a/python/raptorbt/_raptorbt.cpython-311-darwin.so b/python/raptorbt/_raptorbt.cpython-311-darwin.so new file mode 100644 index 0000000000000000000000000000000000000000..29d75cfa2df23f4148bc43fee695f34ee417a4a1 GIT binary patch literal 872752 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

PmeX7=dKY4VYgX|^4_K!*Jn#h`jM{4g| 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 z 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

4XO+$()8|8;h7b1x=Y^mu3!GTjAk=uEQC6UZ&V z@euLWy-A!KPH-m1K6=+7=2^Th-@I3mKlbfa@FTLm<=

J*!YI|`0G$)0%f_XDiY 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�Ce@MXDR-DUx4VM4RKJN+5yJt9PK(qb9SA8DyO<3}|>K&uiY^%@Nh+>?{@@FSHzfd?nSlum$5pBjbi67Ov**W!<5B!uLk* zE9k+Sd8fjw4E~8v^fbc7fs0(rxZ#P}i=g)CIhHsGQ+F>b*cU%d81&`TC< zyC3*{gTufNw=%xTlD8}SJTKg2&JBFVOOmVjv)Iqr#=P^(MST#*C_T_7NQ0iEjJ_-S z?j-J)%(6y=->L&Xy_NBBL40WXr+!W`sAct>&1`?*2lfI#34GC=Tz-2N`{4(gEeXhR zn)Sa4HoT4T{aNIXdF-!x$0&ja>#@+8`>~5P2qJG{D{75I4Z}T_GKVgJZ{)zIB zBL7s5`N>`vkbjTJUscHEqF*b1^Zu7}+1rfw%5Mhl{@`|vg{tih>lvS>P;49cCi<9f zK-zy2`_6sWOX%e^=9`$L@kI}gu8AHJJv4!BCiK*kE4ZvfIWs6{&BI(y+L|X!2lNwq zIUw?Q$CcDBRS5E{A7$P)p+@M@<)_TOzjiaTYe(RyT{^+nB>2>GQc3+e=5ZK}b37Xb zZhD-@L9z$)u(c;#w8LH&kiX$EmYdqaJnF6X$E{UI{<6=(CPn@Rksb9^``Ky9lee?O z7vg#y@=rf0^lkM&-}n(f2CavWMhSQ_;2C+Ed3vmJi$Yzi%$Jf{C172*4VFPUKcL=b+5-M74qp9IG}R^YpLFCAZgD`KJFE?xd6;M229!;@Y0 z179)Ec-wPKp8AW?#z-qBmbbmleF6*MiTtO~gXIs>+5G`}1#;-#AY5;*z-s9{jwIXA z<_n^nJlm@Y`7^*T3jB0L5X6I?%YmG|ZaHJ&RK)XwpiSVr@bi%Oe^}m1tKVjQ&m8R! z(cdyhl;qEn;2Rrbztl*HbZF0)n`4E|pHVw^1Gh)uZ2Lew{fFQCV5s~l;`!J7$|qd$ z7vL9ul0Pm1T>TPo?Z9;`0XGcXP@cGQ)NcW}S%F)RbMvr`7qo|04*#T&vfJR#MgA6% zU2jM89nWG|qAY|<0$1_^>#@VaIpSj4YGVtFOi|y&lT8~(<{gmer9v2~ys=>8V ze3IK1%r(4>h{2-aUvZ<6uW|3qo;Kqw+nxq0B8d4I<`D@(#y#f4vi06>UpP!!j+j9@|%lq&)U8Do}_kbUHy!pwmbrSzy z*}qa*bVOGuT~9mr&C+`>k;HT~H8Bs;Gwtb|dXsr%+#LO*((`oo^>1?@9`bn0N&K`& zs{C)v-y_Dy=(CD{BtQ?lp$Fvg<~RL2_*4JO{D#!SHpM^hwmEv6C6Xm2w2^?EQ{XB2 zJM)Z7p6e9PwD*2j8nJLma*4>G4t(XPYtP%F-jZ*#;_Jo3P44%=@QeM>R=8|&ss(70 zlivN?x}EK+PVydY@%qR)N#nIHXFK>N!PjAMw^Hr1Qt@StPh;URG>;ykV$UO{^d6~j zChk;-V9t}1)CV(MuMt_V$9Bv9?wALe+ot6l2<=DyIs<(9HyB?}q&T7vDZDiwZL!9q zD&#TyW-sHqrO&!e;hOFD>0@Eqg4L|L5#fsqus-!I#`of!j_78Ecdj3{Iov7FZ-5^Y z_&p3aq8lu{%Vmc8?XjrYm#H6Qz|;B=%b|Ey=f{%*&kT5qA7P&LQXg-;EZe~|Zt`EZ zkA=GR*%&0IauQez+TE{}v;Cbdhy2eV%Bgvh%SlMPI!Be`b03#pjHSJo5X-1qidm0h z+ym+zVE$3bzgqG8>&x>^Sf1)K$HO+1v-n*ur(Tw`>ld*pQ{Z2CN~_0K z#s7}fgZx)KiuLb7E@uGebVN6(avbrTxh5t9I=B@px--q*)PFbl1_WQ3)XQIfF7=W{ z?jg5j#%S@u<|W0gC-UcuC};N&%iSZ(xm%T!_84d2?~pA0xGBHm+-%pn{ji?&edg^q zwE=zHs(8KUqwrzQ=_pj=OfZ>9?m_UU1i$hNrzw7iU*-EH@GsK0(sLl7wx1F)^{YbM zn~Xfma*t6Wj_B)?Qt#e!n;Qj`MrgG36rB7+6ZnUJ!2I(5MD#JmpQT;*xZBXDU8$T& zlr!`^mm}w?(aUlvXDsJ(Xq>1*&^7Q=E~f$KbVT2JN$SgK_gI;<%%0c5+JK)B_-2M1 z(U%n7-HzbU+l}mD6g+zbkKFGXeMs?m*m1ZLgPy~1(P)lyvWJpF^y`17K1<1aIRME36+6~Cvwakz%XF}u%D(Hi%YO58Kr z@oVNyz=zNg-Jo~_)``MHxYOgYQp7wYcQ^REf6x5#{p8VC|6STuK>U_M)4F3ave!BA zw*QfNd$>3w`n=+G*c-jRMA{m8<^=MWuYyhmenINM@~G2t6`6G%rhY6xl8d3 zc<|7hVv+9TsRK{Xe=yIOCB9px{YF;5C#@gm&k_;2Q)K&(ke^ftwx4mWt% z1qGP2A)bFP54+;9{D_>+QmkJ>qc` z@eR}nIHFf5oWl=N{<4D*bLsid@y}m}{4;v~4gUGtk-u21cQlYNIHCvn=O0A=xSs#5 z3E4g_`Dc*7MbH15fBxcP+NY@L=LP@#apa%W^Y8S}--7%Ru`bhK))w{M?wy~;tv=+h zT*d8NDf4et`JHhVZg1G*EW*zKzh1{*mOH-eU5JHr{JPxnDd3BA{2MRI_6n505BP|V ze=c|Y4DfsQ*YtOH?)b8IQ|zGOx8#PWxFH4nw7?h2aqMJ;_l(2LeOdMxOgQQ~@C|}5 zBlOWE`Br4-IZjKo$g;nYY(e(1LD2)74?`3GW=$Dh9){N?8`f0>m3 zX2l<*pYd$^q4t~s|NObk-z?knN{ipyZt4D#d)`L+t}MZLc0TjT-#v_;qxgdQ8STUJ z?PtW_4gRqj=2!Qx4p#g@?MeF=ecQ7M{0rcpPcZ+2)b|_W#%a*+JDWc@=W5KD`3Snc z(E&Y_^q)E$@mDRES1j%SK~ipb>M4O-b2dQ5BciAS9Uq`O<)Y7Bl;I{BRr0K#*JC}i~aZ0 zdE6;mzH} z`t1X=z3ad?d!5F2&C>X2oah5z=k?4tBim=Q;!C^NCFp(rNS3;S9n!cw2cGpeGEb=- zm(Nr@`Nsnh^U1iZ@jwdYrb@BSc@vjAE#-ejY#52JnqHQ}`T+gLH8RQ?@gN|BHY8>ojB>{B<8r2?Uf;6S*R$i&%|^Fhr+!fQ9*lGMYkaRSjjsxPN$|})zzZO%)co6!}+S6 z<^$pedX5u$oF-`oW#Fc`KDiEK<>Smh7vgfGS1JB3|NR2ydxvn{;+9L$#5)Myu_uMx za-GWN4?KDKhOW8FX6KbpVi9j)8P=$t*5uu^ck+^+Q{Xj*n70M@uIPxKtawKP^jwN( z;E~7Dh5ZMqh<_CP#s9+mQ&JySDE{dH{%N!$@_6%Cg1_v&h%bN4{8SY>qIWBP-}SI0 zaBTut&oCoujROtpImtETr2^S)}vjRQBJmv@-L z`IZ+s3jSHgy`lPc^&scFs=o-qUTwks(ueCoNxX5fh!X@rO#Ux&R5PE;Nm*& zvS3`v(Xd?|w;>o;2VA?33kTymf$P(8uZRt*c(06~y^I1ks^fMB;}(IN(Q(^?ah2;4 zGmUZksdm^BjB5g}OyE?zpQ&)Z?cN96MuAiP?;wTq?SE6Y@)YhbV&kxVKdZLhUVIGf zQ!nqiU|a&YMWIhs-u7TzJ8-i)?#5u;FmRJPZc{LB0k{zzw=Ni0ek|_62%Kuax1N{l z48HA`0#25v?Byke^R?>?aMPkZg?lU*Hwm1f+vnC`T;T@XztC}41mohsRqMEugK=%Z zHR!mN!MH)-(gLU2;k6$Jw!<87-2$iV^I3)S>_;@dmX%xOEktQFqVZ4Rg5t12yuiyh z4oiWn9sI@r#{Og)_j2io{zdUmhjW-8n0M8Y(>aP;5i`X%b10|tRW2tZ=l#zreW$a; zO|&-{*X`^a=@D=IIEfK1kT9u=-01vIq?d!7$f?eDkq)8{H)-@)didI;)5twh>_o2+VTsRZz7FR zy;@LSdhK7$J>?~cS?I9EgE`HQqFD9LHa}_y=M>7RjB`2F7nsFh{af9)S?YYS;jldC zgQ*i>dz~!DY>AL#tJ;s0Q+;Ox=7ZFZ<0z-;5iVyQbA39Zud02E`Lv?}^=55H(r*=P zWp0eit2)&z&c0uLsrnsWm4|*?Q0yq==>0p(Q7iTP;%}rs%zs^@4ZS_!J_p%x)rr`j zd+3{{9gm$P>i?HlWx2VuE4@TIn;Dz?sQXaP?gW=J#KjoVEx(iH)H>(UxJOWH&7_1Vpkxi#pZpF7Vyjq z9wpDKzm@gLS)O6=%xu=`^=$q;k&~g{TIPvL{oJQ`yw=xHH|P3V74(q+PtPUHquS{j z#gnz22;T{O(WM&xoZRu_z<22Q!*a(LRzir&wDRAWmhBQKe**Yc9sfdZc(Sif;O7Ni z*~LQ&pR-*|f@h|V>!EmV%b%y@6xhWFHJ&T-=ShKQ{6oy6?CUhelaqase)_>P_7Ub$ z_ik%bH13Upr)~@LEXw|SmEsx6^1gsZx;3wbbun6NcBPV%aG3Q!4fB$e zR{yoCyqxM!U9Al~6IU^hn%}&o#x37@(I9a1I_{-l+#GO4jaqq+1>?##Vqc_=+Zv2Z z0#~c!t_a3;1J|tMP7cP61J|YFR%XXhdqz&j{+Mf6FD=r3Ui-E5dmjDD{EZbyzapLl zc*?HT>|&?l$;mEg9Owp5aWnI%{&|<;3GAPfz^87|^HrQ%TwdXld8O&^_T)r>WdoB zw)}YN&>sEZso%;xz3P5~;+b&W$1kW^QE)*t7Oq7+i>q>0X3hTiK1BpZ*La7{1t6^-}iz2l|+oC0t8 zUCcXE^XfUvGYOu|6B^H^{N*V*8}ZbW8jtl{dd~8sz|%gU z@r3i2ryo46-_>|tRnJ4`EYAXXnx4{lp3R@9>Kv@!@6>qi%b%wmJavN_&yD%<&^R*= zo|>nbX9(wX*q&S0#u>OIZJnqP#yleM%8rlDUyeHP3_hdD5mr2ba&!Yrws4|0z=*@Hy9K5Im7ljb~?mJXD`W@az#hs(tQKJXz~Q_$u5}YW|U?kE?RW zw*fz^*>E{o=Ke35xr8$-5fMuN&(;c8%>TAEPO6cJs$DI|7E4c*mfsQ*k7*_|}nm4ufdtL22@zrN1a2s{p z^TD`L;OceUgTc5(;97Ou&B3_JOVNHhZgX}V_2(wwh6HXwj<-i!IIq{|@wRn(q|5n? ztK3k~2HsKdj)wM_`!FVsrQU-6@|t=ccxn4k!mY>Vunol=XXBS)es~<0UnnQOQ&5q6yP| zG3ysbsg0=JQ}{D^rY6tBif6$8{%p7tgt+bwagyL225;tl%-bmA-)j_aZ@_bz^!zLG z*tz9X7MQz%zvP1$d(UJ3V##kkx1J8*F9JXEc=OXZ)CT_g&CIW!|9xFO|LfLo2(Nx~ zs@Jr?QSemO3La^H&nX^{csM+0%4Rb_lBe)PwEu*8>Lt%ZS$Sr4o^tTifv5Kcet$#- z&gqD5RXo*lA3LR!eY7Kg;|pAFi7a=E%J2Dp0QL)QDBk`wzkHfS#J}Jz|0(m1QDHcu z8x*g{IFFZHYVj}WrR;Ld^?t@YY99WkdLFgH9{+AH$SjXhJ5`7kXJJwlM>m0Ie1dtp zNf;c_*SvXRtri9KBeKVSl0&@Ty-4cmd4;dm`y)T*-2fd~&ykWi^y)Nc3Sw9A7b4i0 z7xFEVa5$n5Dt<>jW7vGE>gaf*9D8QcTM~Skf8+Y9`G@sfvxCpnoB4JIr{ajG4?OiR zGtWlphb~icdDu7o;1QB#*Mmq+;F$wY&2HvdFL_Q@JPvux_vR(6Dw?m&Yr;9|YOA@K zjFnU=G@YX))@Sm=_~(~g-lQz=?V#ssX{>WUS4(>62VeWIm~Rgif+PAD#pi1On8$lX zshtL#*y;Fs(={t4;jqPPx$5cDYF3C0F2& zz^n1^IwhBf-G$3_e@Oi{1)heVvp<|JKmv~F<%*{_;Q6!??4L&-Y0S>|ACUe=!M{hy zIU6zQ@jfHPKkcu-a6h&c*-r?c+-f&k8o%Ze|jG6!>Q2f6VxI zK;kb`3x@lOQA*SxFA zho9_e3jDK$%s(f`x$_l&v;Tavp2lDgugLwq^5cjlRx$4=%Aq5AnBsNzcl!@tz_rr& z+X8&^YQ{GZ7)La$@Xm1w!G$$0#p3oe!V%vn_|k$;#n1n&p646!@uxId!!@gY$n&Sb zSNsX^9>DUINWJY;d~W;4J74Yg?}!b}2uX{WgxqZ?$2d^a!*!~hy!6lns&Vke4`RMj zDfb4&=aw7KPv;QNdASwS?^kU>EG5cQ^NTmtb5perza2_D?zdCBwE^GwE|#}L%KNIq z_jv=Z+{X6tNPjmOQ z@9U1t_xiVhzv>W{Q@s!FZY8H{-V2dTJMRas=upNLOMTs;a1HwQH4a+`v2>(si}@D8 zH!1iAB;S>aFYT>=^H(%+&Dj9do{3Lleo?~m^+ZT&^y@PfpI^LDjDD!a8!TT2e33WU z|0?@ftN6U%%R{f>a!(&j`9KZSa(W1bPo^NM<2 z(}M>ltMRk}*9M*u!IPHye^~MO&-*>!vyX{|z7!DiNE7nL@x6%h`;OsysQz-MDnF%=c_-G`)xh- zX}(s;t~W7%rSY?*5pm%OERT9`)kBIe%e>6&Bc2m8s%Hy$Gbd{GyixIHtta6JfiFCn z@zpq|Bf8lKugy0JzW{u>z}HAUZBY1Tr{C^z#JkkbD?g1-C6}+>1GrY3r#x8Nu>F1$;YWdwY}E95u)=56C)sc0YOHYyf4^Rq z|Hi1an_iFg>oFRG?CUGUlK_t);QvQKU=XjvohyJZ0A)E)u`9r|jiQ#p6*gdPy9TEgJLvTTR6C z-z?9Vtk-Eap6u@tE?Mr&&&l?k@X$jk^ni4$0yGaQ zN07BBepgDZ8$GFbAVi`6^XU8|^d5p{m!JGlAIhy3zdJZDl0(?p6RdY|IepOfwN zdQLV8Oon*md!%K(we&<=LJdUWv+vOcsVYce}H69+<`oPy3XF1hF z$c8-E0Z6{8>k$WRV);gKPDk`M#p|ALl1s8rl#!o!+QBm*c+~x+tFrNoc=C*cr}|=+ zXP$(?5xrdTH0bw-VxcZqTu$;7-+*DfR^vG{KOX7_N$^Yy9@Wp*DxR$KW0E5S{HSQR zL7dYOU7_$^<1SVNo#QU~qZ#m&ULx8_+WSi*(jKS1{ZY6c4j$Jm|4E^73-~H;L?6A3 z`O{-P(5-<)XVN}Rt@$$M}%)EKvae=DM_y17t%!j$$aaryis@$OQU=Pam9S=y~ zg{WNLN10#sxAPUhN55_M?6-B`sr#75b69>nDaexnPvlDG89j`&fcAg=pJlt{w!alG z@65KpmHc7kvzWi#!sV39_-Lz=*X3t;T>-)dze4eQ^^*88DAKGYpWS?V-ryr%u%jCoi)^QrsQ zcPT!PagpvnItM}GsRPgMFELN8lxu5No(Y|&4m=s~^n96lRJ-1+cs$0J0q3~jv2sbg z3*a5NlX-h_PDk{zEWC)!?8BD*x1r?y4yt#e6*gpW_nb0W$zLm8Fa2TV*r`8~uNyp- zUt#&+0?Z@&(m%<5@2)r2LLBlrPfa}W&Vjd9@D7#;-a8bpZyaDQYlb5S>al=~_v4?# zIQIz4JHxa_^bJ*Sx4g7H!7eZ`Bwr_Z5|1;_sI;4Z&c*{%w)4a^MS#;B|G>L`fO$Kl zeA^YTGaiLYx7L+lK-P(gBsnW@fxi@d>iO5p6`woK2-CaA8*Bj{&H3!-EN3Tp`=4St zRewHA@p{fjVxe~DRK&J^H3h!Woy^xS_4!BD-X85T8mLdQ^U7PXzw>G4Rr|JfDqfeL z^!nX~6nNUeQ}+z>%u4wzd-SZYIsXd5UFg#?N3V= zR-3yFfcPrgpw}VhQ}%SGA0L`JfUg~Vi#p$%Kal<1DPMqnRzbc=@Kt_~O@&-U9vs!9O6|<8sA6?fD);{$ax1 z2nFn~AvCvhd8C>CWD4c%8RmM7%eeOhRZiM}UkJZH-fQAxw*MC}mj5&Jwn*NS6|YM_ z=03C#e3cb1k=&+c$)B`?uj^l!4^uVsh+e7q-2F7AbyMf5iBID16nH1a@08cbeI^Gf z-mZYSYb-(w5^jNi+osW(QmL)T=J?70u+%skie;abgRNS%JtkY zhrVhbo~Y<{d|JS}7-BWiZ=cfW#-4y81E=YMFR=lq9fX7S3s@8ZQZ-<%f)eXLuW4ZpFvc0ZR zd_L{9$Fset|IC1IbOZAllJ5kI&%G`Nx1sePs%QC^5VMst&!DX5YQ^JSPwWlzsHR!Z zHtkGH>5|xZ=^*cS$|wv`$pK zGW$9a*-`l&7%x7=<@9qgc)zZa-@^}Mz|icd9Jm(nw10$odL+-oipQ~kjLPjp{vB6v z{-_);x2pW^b-6G-mf>7bH~T+$3O~U-9U(3_dX3_l4ts5gGvm&1E3H9=J^Vk}NgR`a zsjHY*t?Qhrczy2A7&Ir;mtoBPLEYd>Uc-EoQa>vNpT5aQT&Mbt12-<#ABH9FkID{$ z+lS^*wpl7s5O3L+u}`auRAMo1!DDjfs+Q2*fRpuR`L>$p;6t6Q* zpnZhaM8!2PGwTn&@~<)9w8;j(GZmkQoQM0Jg?F=@h0>mPD!zHw?-lX}iYE8~T(iba8j`8rqu?96hxt;)EMW9D#TO7ymKJ)& zh*h8|*=88cj|w?U1@9)oo9(wVk(J_vB=A)~VSK6dpKq)7@bSl8x<5v%xE(afHw^wo z!LQ=_XBEG*T`=5Q&BF360$=lDXVzGNr%I|zO?ue(g~JLN-{ z(dEkk-!1U#rT$JtOMJyJN`K0H zDJk!hiqBi#a1?&fsZbJy_%h(@eUbU1lFzbhr+f*Acoe-_(h9yg@YPRf^}S5V=j02h zFZH{se?&|!_-dtm2Pr-ee=`>7Z-}=WynX-1a#l;;zdSAdjR)^gPP}vA?fiGorq@wN%MiXD_{Nu5u0~nk%?j^`2h3Fh z+y1Xg;75TU73&!?>fcnqlZa-nDEpC^`l9W`@fifM(X*kr=*_U?MKga z1vDVFUwJp|R`3o;-kplqNABF>1+)Kye~;j&^5}?Or}*7+BP0$G+w^lY;9V5F^Gs_* z*Ja@ibvlWGA^FPh!MOAW>$O+%y!qWMdd1v2KtQtFcJS6N2z!vcPbyw#{|y+IiEk2o z1A?zv`n&5CpLaWDi9^cn#hCn8mQOu*ahBq9%9leN(gMEvH?{g6?86sOUuu_8@J079 zU!&CD%LD%Hg5{lnW-Hp>l6RZp_0}Uj7w-(`Jo{-2_}ai<{ucAMMYw?I z`HFu!;QquE{1)<9eoO2lYVu4L@>9QBK)K1tTju)Zq8w-6d{XLbI^em%QYzQ)xj~8t z>aeIWzmoZfM7hzOia)KrpEpf=QEZR2nh3Jv40sv}nMbWZ-KBWE^&gIV#?w^qIq(&& z5^~9Yu{nsZ*2U+1e*!OEk{u@Q$L9d%?>Ne=h7o=H38~lY<3+e*jn{xt1)4taj@L4; zT7TGT$?beT0LE>L52@ZWz^5)_yt-e1jl!n`>}(MVKpx%BxPDac_ybrYX=eUzPR8?) zir?+GweXLA$JE{*iGu7X1Kx$}nRin1zNqx+=C!|*LTpSS`DVbg=LY6!l5xR9if1Cr zx)*I7$91dj^1cVzXVrGBV|!p0xDPE^v#86a#ez?*vusd&&UpZ0 zE?YC3^Tl!C+XTLRB?}Z?sqoW2@y`I-7uT#h(z;3u;3XOO=``1`Lh9|M$E7`YAvAOE z_ougG;EJ8i+@s-2+vCbd5?A8WjdEMIa=By&bVRRHK{`5SOfM|6wgclHBk zd;mtPR}hZ)y1_Rh_;ynwj_6t+KH5uVQMfu5+3^hcYPPX_(U?hx{aKGm{q_djuZu_V zaFP3do%!UPM>_M9JtiN-KEQ4+SH&eSsB+!@E62EG5WF4#!TS!>Jm)sW8?>*X3_i_w zUjy~G;$GaBdY$?ExH?Ake8sPi-{|n(<98A~&G&Nsm495Vc${`o>u5&D8Hqsp><3@v zJj=Ck7^T84UU*ctW5D=2UFUaC-S=Dod}4BW+NCQ{Nf?vPk}#q z0Q0XYB&i|yvxVTWpaqF_L%k2;tw&`0rhUdQdVU($Ed9{8_h7^ z0?z4(zOMLu)>(Qy*IDX-Eq@4p^l)J(GJf2t__FM$3a3mNZE=KrVv6+D4&G!b^Q!S> ztKxO}f2s%7Zy30_Z*cuml!zmGjlwzm7kX(HH)*b87NWD)J;;3H5y5w~;LG+~v&dS7 zPXhSq?=U{YaO^8q^3>YLU-KOiwN}(0NB&OW=XWrEM&j2ge1rQwJ=Vk;)PQ8}(4hV{ z37+9!Fi%|0Z(iRa?I_4UPG|Fvr1#2i!$}4=H|Ed+~mU9qz#sSmJ93U&UXT zPpwaE3F6E6@=bzoLg!nzbiT6hzz@u6^1b%3)Td8<{pD)`U(tUspSrK}P!QjkZ+%C> zH>>kqwKP8J=fw|W{quiWzG#FSIJ!ac1&lNF>MQrcUmP?|;B5@?__agwzV;odN4K92 zH(+RSMxfIoEz^A|-0|GJ#{TUP||PlA8uNamkiA^2bZw$yWw{Ix;+m5;#R9n1WN zlz*Gz51LOmf!}vNP5S8u|BjQGe?sd2EX5xre^rqF7r;MrI`fxF{k+katA4t&@w2w~ z{oSX5eHz%Ofqfd-r-6MM*r$Pg8rY|SeHz%Ofqfd-r-6MM*r$Pg8rY|SeHz%Ofqfd- zr-6MM*r$R2`!$f*BW|Sp|FkN#cksriPYU_|fbUX#^z=`O^b>b+{;P3L$AKZkco)9) z^bJTq0O`*Ne!ObYJn-rjV?TWK^p7KLe|$>sWPPo{|8$7kXhz|ad_MO$f0vc<_x$60 z!?cu7RsPz)iyNsTU4Dt}8OEgG+4#i*^HPz9Pyb9JoZk}P^snCO^7{A~Uq7aU^>v-d zUW)Hx@jrd(<<1ST-1iHd)Z4WnCY|W%w;_G4NBUbxJIEuQ-XltS)_I;m`g=Xn>A5K4 zFa0UCnkDbrC%ImDm6mzXeih>F)$%?2GUEz4o3UG*lWoxPCvh%&Ns)$sL|i|Rvm4KA z`0e6+mAEdybiGbc<#1GdndPSY`E<EB1% z$sXwgNITmj{YywY+9N%UG_P_mM>_SZpJ{T^{vfg|J^dn|^#4IRwY{GIZ%8}QBmFZ- zBR%W+w<7HfkMwq=o$is2M{qpz-{+J6TS!0HgJ&9P9_i%{}0m1&h`9%LmJ7ir<2~u&h>QC&lw)+q#x3op8tDDBfHhp zUqu?(!!cUDR>9t=Ts@thmnYw(r!OLn`usV9=TQ7l2gOIl_~_}Rd$05ZMSAlQG+J>? zLEkdY+DL;TjtGAlUlrwXtKeCRcZJO>KmP=$KwQs**$o$d^4+=NW%=7h`BJ{_19QX6 z@^$>|LAl{&`45Tmv31xyCf4MJm*wmDvO{yj%krNP<;(aeQj{BBmapUIa>Nf^$Pt^= zyZn}oE#=!S6iTMbM-%R zjv^u7^rqbSrF=TR~`NxU!S99@3;)2}pvV0xC=EB_Yt($Z6Ut5HH(tb+nbK~#1g!$$8C-I`1YW&zgyJ5aa(@)H?{hA+>{?aau@p_ssFat{O|_~yo^5vug(vDxWLQy z7)j=bKT_bO{Y`u}Km73mPq8Q+={xenpDpkdx6)B7UJRV8{nZJ)^gk7C`IWy#!#90C zKl}{>FWZ0g=KSz!ftUTK@iY11I|W{jldYf54}Y(~%ki_}w*2tl5qPQpg?+e}r3H#+~w8w~y#o zx&KT3^gNs!zm!kMcRrRIUh3xnQNFbQ$PaSE%kp)6{XgY~m*pQR%9s7W<>}n;vV0vs z_DpVgS^n{&d^!F$Kb{+2mapSSpU4g0n#^t=pA>S*xcGETWN0p}JN&mSI z&nHFt*zK$dsh{+3*fP0RhOt$g%ldbSbGg3oc`bj@m0VBBr}qQdAM|gTf9osG^7oc+ zT&tfh|37N-&kOlOLcm<*m-)Z?4)b-t!+g3v?i2iSKJzW@{BdzE`|E_JPvhfDYj3h) zI`Xx@?+baP{Y`4}bS+UHX@6(4SVpsW!K>c>rJvyXO8MotZ2zuLb4r`&&r;4WigOwF zeM^+1<2MTYy&}Jq>j802_qgcr!*>h0cZvLRoEZ`4GVYW34=|lECvdf*ezN};ZOLBW znXC7%zMTTU)cVc}eZ4H|$I=?cYvNqqm(=T9Ey}GH^^^5AQqKB@gns1r+%;U^w*f4;~s>;EBfen9{}F69^br9D0)&J!A5+Gm0`+~BAX?IZiuz$d-iOVAjR z&oD03&GnP|UMqgwI6q3mpDoUZMY+>LKT^KwPi5CvLX@{hlh?S1^|#5Vyk>zby2{C4 ztDVdC>=yZ@J_N0i*7D2tkl!1xL=r{T{ zx$F<);#}6}=i*%ISJ(F|;=1JjgE*Jt`q|=K>VKe#TSl!{`g#QC1-X8-e3~Vb<>~Sq zAg)XK4;AOK{5OO=viyqAusm`+&eb0Nzq0py+v_Y*U)f$k?RT!oFZFwwIG1r~xj2{l z8WH-E`E%8m%%}T@T5Uh`7esl|ezu8o+1^3=(Cy(~4PT`7mv3t2Jt5BJdj2)yT-Ja7 zI<^-nkF1x|THR-|J@op&)Bf?-T781_m2ZFlv*4HYZ_wKBzr}Ug|Nbt{r9WDu=|B2g z&ME!fzl!{FUOwN<`j-7SSAEO!b$#phcDg8E`aj)%%EWbP4-Gdg&0omz^fJLG{qIM# z^N)*jS>GGQx$F;V?ffg^oTbA1$+YqI{o=Zme_qHh%gxBb1vSc#; z)XUSKe~>*#=09A>FXg>mtVhcBDs5qY>5p^em-+A4_;tK~?kBI5dsd5&_f|g1EAJ0T zd3Klam3;N>_dJNycfR`d<6EkJ{rIFFo)PUO+kcy|2kDP0Zpv;Ce*9A2X9d6XN3&Xg zD%JegPegt>9_segCFGI)N4B4=kN!N5Oqbug1;6ayLH)^(Pqy!jChy+z{g=iUWbc0R zN&Po#@BFiM+-h_zk3`1&- z$G6n}6U3Kq{|VyDxBmq3NxN8gA4ixn&f54Ew^*+G;Eob_8E5nu%#DS^zZrU|4vc9w4XWggV{m$U){zC8UN_x zg}z^6B@E;gIA=ZmaHJpNk-i_&PxMG%h4d<<_Y1k{8ALj+#fRofdU^}ePWDJY+b918 zBLA4+m-j7CLpt?Q)~;c!{}H$IZ$&;iZ~bp^{!8SgEyD`~)A|h`@bRG%#lW#)g*KPc8bon*B7Z6Vr-S+ujfvkA z=|@l^j&|gyI{sdyA8Dqt4<(JRc$np#(bI`_13teN`Dsj{V+bFTM^C5z=#@_6i&r|0M_%c-z|N>2J@^RM z>oELJ#}^Rb9EEQ^{SKs49rW}F6iaRYtl*)sm5$HjL*tsB{yNGfem(s)pY(B`^eIs< zo#$laKfr^ZewUW)RnPwj(nvpg`lCMSn zze?yqPrm`_WghvdKU4qF^WThg;?&bWg>ho{Cm3$VH^s@vKgQ|JBK>#xruHou;q*q4PJWsC&TpRO^erO&nGow! zPyfD8`aEzKdGJi2n&ew_o`ax-GNeB!M`1p-m zGv8%BC!gZ)mYw{)x9=Tnjk1-LqHH>3`;7mB`8m~k-=4fK-w)__y+gePd^vwq<202 z-$6t2>giucI{86et_#6K^_GV&S8{*0bHD5B-(4|Ll~hR5O>&uTlVYk-6Gbvzs0bkyg;0bLq9}wAq9TMO$y*2^3d#T3bM{Qr zQJ?qcyZ+zbTEF#M?bb7|vtQ4?KKpiN&eO~+kliOqJIu!gyny=9`>O8ND+6r|5TY?= zD3kFoxEu$*7Cs|@`vTNY6Z}1)8kiTXlYjgE&?ElUzLK1$U+D*fV)YqLM?ck2nNdQ8Zg@Kx{$(YQi zDnOi4FkYiDAC^m&>x1=ZKSlF~%3lT!+4n0>kL-ZLMUW{VRQ?Da4=Udm<}12Su*3c? zaM@+}c9v|{QmjQ|h3*s6Kal&D2Id9*5&KB;i_dk=hx(%fLgl{$rwei&r(X`&C}={v z(b+-ehX8NV1s@4qSLvgG?<#!(@Y6x4-`jix?1J`rDv!Q)3e6uXkG>9SL>K%T;D>a< z9|I0qhFZ=w;8eQc4WPe<)C#Dg70_d!8HmC zVEfSqyp4ImIC}tx_;KgS@;%`i1vG|e{t1F$+y&TNLe6*fTO=mo{Ej%oKEQDx+#FJ$ zfO$dw+Os6@hB^X)x}L;%B>!*UTTPv3=zVrq<8%g(_j}wALoI~^9Dqpu~B)n*UEOmPwIw8YZ#>$ z1VMWRxJVA2B!AZv032F#)cON~)9i+~?t%{j4$VVqIb7h-o=)Y%fg9Kbp9>t?cc|&n z`Px-`&^({nMfy#^p?OX%2k~eQQTaW2hv4$XfmAJYxb0Uq@?9FIQ} zu2Deq0DZLeQBIJF-1`~$6f>id5?n8wI{ zRDKz(S9Cs8c}Gcd-B5V~sz>_}m8W&X>jRJWIcj>-Zql1|!>{P3oCnaauG+r=^Clx8 zBLwQXP>%WHk)+;kG>I9-$8wyAQ+7& zQ89q57uAIVy7xuFypZJ4*@{9Ghy@6hUk@DGOG21rc^Ytyg5aFAb|v{{jHH(Q?rPC= z=@ZFx85p}Ok8=|y2n3_sBsSo90-v_DYOqpwl!s+=_7(f&hCpV$qLzD{jq7wOZ1 zNB0lZa_)6g&dqN4LR|i7JfB8DnJ6feASQc}oKI-3pr8kW_AyJ$qjLuZ^gaN!lbRmw zA!v@S#pwm*qqfiJf=6d0I{bBv1y08xI_6XLp~&a5czvO8wm18|;YY$^u-^Qn*IJ1$fjyDt`kwwBG#j z=jRLI8in&9LqVwgX|NNr2bI4LJnGABT#oRZ27Y)K{9WKsA4gmv$9oZ6qwo)GOXyss z^0~mHIYi}8!#YHBA_kY^0@o-QLVuy^ztSUjC)A!=oL;bhoq)ue!1udKwnq!DQHTXW zV0HkM)9ddPm4lCFAk$3He|Q!zkbral93`6WIg>^fjGmJ#cV4o%k&5 zrO8Y1Lg($ksolVU+imSf6=`Z3Y(!jDE$G<3)Tm^M@4J(G3Ev3 zcmjv^DJ6WKqq&Si1IQ2%D*p{QL0-5N252Tfg#97swH`*t%Vc~JMvLEMyyzE+O*r1& z_@v;^@l6Uw^VysfW6Rq5Cm#@g7Z?K7oecOQblCD zx@*7Sb4cp_lHl`!OBB1KF+$@?O^g`x-bbd1L^8sPeQ`18&L>`!!eHJPsl;|Gjw7<;WD^@A~v#F&or8{qgfj59Gh zV|2slhcOi6MvQ3~k76vsSc)+gxA!rQ*J6B+@jFI(7}-t*jA|GYaQ@LaJ{hAGMrVw^ z7`YhNVT{L^jB!84Ll}GPN!tB?%bsF*UX9|Cqb^1R zjHVcEFuGv$!pOlGhA|dnBF1!#*%3oE2eO@V7*#Q9W7NZFg3%hI zBStrjY>XioqcJ96Ov9LkF&ASI#!`$`80#@MVQj-F<%q{0qXx!N7>zJmV6?-y2%`^1 zE=C^4c#J6+Gce|0EW}uXu@Yk~#zu^-7#U7@{4uIy)Wv9k(G;T%Mi-1;7&#cjFvem` z#F&mT8)H7kVvOY&t1&iUY{n?&jK?3NDn@OLdKgVGT4Qv?=!TJvF$7~Y#srLM7_%_u zVl2W~im?h~Jw|ve*Jb!W&EraWT~^)&DR?eGRsB!fiSzTQ<#ZSSpVnJh0uM$Yz~hwv zDh$XZKX=l`_+MobN%RK?0Y%m>hzXG=y!w}m|7m}z#dVjT%B%U1{TBV75LA{9=EAtJ7WICOlCP&6 zia{vR|1JAaiUof~%76+IR0%HrOEm2!opm>SPB*+yH@sapyuh!Nst%zNIG)lCuh9)3 z+YRqV;s0fR3se8M>4X`2E_y7`V}TwE^jM(B0zDS!u|SUndMwamfgTI=SfIxOJr?M( zK#v7_EYM?t9t-qXpvM9|7U;1+j|F-x&|`rf3-nl^#{xYT=&?YL1$r#dV}TwE^jM(B z0zDS!u|SUndMwamf&XhQ&|6cClS1$$6&=kvs`eo8I>X+aC$7km;wbVYr8pW8n~h>p z_7?v^rgJp@gH|->zo#Tq{ChglQvXj~|9dO`n-r>GP@cFH#|Z3b#G~U2egv^dWy(_ki&W8mB%L|qmDqj~y%bYh9E)d7jb1>)0impC$&`K-9iv%PApyMoP5K#^uU9e6e zC0`HA7IgfG1j-0G_=^}W8E{D|c}h@@1EfUhK!c+EmZ0(>uE>u=6Xp1|I6{k|Ap7YK z=8n^y_Dq^ zLQBkTE1_3T1EpRAfm$|?+#08vGVMam-Gf+jJ(jUNxlG4U4ohs_3dICI9}vJM8GdxS zJ0~cZ%@Sc|gm^N|xZFTrk6Bk8OC^4q{k;PG_|0(V zvdlxdtRS%ju|O$uI8l^o76gs)^8_iT7^grlEecI zHklsa&t-*xS;PW$sXAMCKYmGiRE1rDFXV!`AQouUNr!7O<&((Vf_kk*8L~RN4hjZf zCJZ?$&xOyzNHeFiS?>HQ+=P@Ri|grwQuzoeE8mqYf8jKo;|>W(Q*nezpi#mE^L@EK z7HCYx0z*0y1O)oIv%ze_WJu2=gbD1x&U_QaQn(o*C}lziYtIVe1_bi0meRpO|Hv7K zQm1#~JH{}pgP$Mh%VjzF2fF(PxqGnrrR8AlN_RFBSK{as$a42WMdphr9o$#4Iy6OC zBUq;}v$@mVgM;{GmUN+X40QKodANHnN9C4N6F^6VxvRQRcFJ8#CLoukS&&6QFz!o( zsKPQZz^_BqM5?T2+Bk4w;xlLX2L+SnY^7-ISuj7ye(-4gks@~}VynnBa{|234nG}c4wp3! zwWzj}0!$-V^$n!LHrUSt`acu)DgI<{B5AyXTCmuHmD}1$VKtrUgNu%8uu%i4 zaPs%{4De#XWSZfRF1$K=(L_z*UJeR}qM;4PV zs)5-9tFqIsnj!;e`oM8Op31RZsr~VBp3s$YrJuW( zS0FQp?d!!ibV^4evT)A;xLHFDOy_IRjSXxeA-;Sr3vuoqaN~zk_))m8TF!))quW1}r3AC&D3=RkUcUV0UBxE?`1e4y=o)x${_JZYV?i8r@-qWF z4uz&piXfJsJI4o3W~L{9>qB+6c9R0_$?j~Ff+xYR3K!l{#wvHR+p$Q`3gMu%3Fz88 zkVRUL9|?jZ45`vl3_9z%iaacEl;yt?X>(AN9Y7ur=o;=t__v)fd-+k8pQj(ciV{JT z6N)mHTeyKtFStJn4B$6E>o27* zY%HjVoWC^CB`~Jva&+dQ6#1elJOf~p@dylX_wsZP;ulc#ms+IWlE0K4177}@PB7F} zoixHZR4Yn*v7n9o`_l$d+7I44g^2dN>2Ix|>#cvQ&{E-#h7__sHYQmnH!xJ7js062 z>@QFz{H3H0MoQNy1kFnCS`EV@IHa;fQvi=P8>T;hisgu^$oBhsVO>7bq1#xPVod&0 zFT#vq4V0jG5Zu*!F++V>Z2k%==X2aZ{&q-atHKnUoVvCAXlM}34R2(;1{7a~ZmUtO zNf<*bkPHMIVd!LIGU1&X+{1ft`M;MYMiS-<(>4Z-jkwc`>nh5Je!jWUmDWq|7XFIAlWr7m zlWi1lR;rfOcCm4B;R^s<;FckhA8qbkJ1|W$O_HZv?3OmGXgkqh+%&C_U2asug67eh zK?-THw0uJFhvJB-qSw=!X!W!PB8y&0tEb1(+W1Ho>{1WvW*SnE2^!%_kStrWkXAx3 zgzlBl)5WtH=&zJOObI=kks-r_LNmm9G#*rx(6^D_1hU$WSJXHps|AWiYB3FcvUdT8SniTC;>vDXv#5U&&CemCvQci{}tNjwRxb8MJs>7R*s@ zEUlT*K*WQIi|8qgXvt{tESPI;M6`rrC6Nn;3Cm>^F|xoWC4?e0l+J@9d4zH{y@-~^ zC?Obfrly6o92z^3&})Oz(-LVpbmbIj6R4vY)?E?7qbpmt(wk{%v{vX?E0IErCKAy6rYR>fqKRyp zshn~NtwB7Nwn#ae$e|}nWcMx?Pk=>bR643hABj;q$Ao5iYI2%N+n$4i6&YjlwC+P(i&jK!H0B(L>A4f3A$NGw9#z1xyA)VtOrX4Y4r68fe+{44PX5*fSQ0L})=JQ47r~6>ny666tNUBKjy7#YAY6 z0f)_XD96(RBNF*>5NjTMQ-UbO|)vFNj4GIL@i_~rA0FeC15`y zD(M;G$`&bvQ5L-l6bZ0@WH3r-l@iS~nu3p=Sn+&D1B`w>qmpoRX_ASS zE0SrJu9R+;j;4cM!6qp*U3L-lE{lklsMk#+;!V?OE;$4zi^j=-jjyoZ-kLgBkEz^ENX)7)hh?gQcS>nAnbhN zi8gv8(I&;_u@)1(<~}soN{0)S!6K^2^h#KB39X_mR3%<6cct? zP$~bo(1<1Ua>bhon|!*Il$WZyoN}pjJuQKzY-E~7D5I%ZPm~TQlum?k1tW_$N;S!I zIEApViY4SAM8lcE(2J+p*c7U^5u7Gi%1tnvvS=x?IWPyqQfL-lZpyJzJbIRN3QVMQ z7||S}fyft6q&G>F$d?iciseLuLOH=xEG5bzp3y6fRxVji)Jo^lm|kr>AFBNEm7B$;oLOsjF*P7#SH^Yg9Ap39keOk5H6?mjP2pPPdTI%c5)Ki)Rql%@Z4C zQ&g>enhDcJx~{2d0vJ$Eca(t!)X49YE;p9oU%-7q6ntT=0(|$95&lA;vk-@<@M~-{ zfQNWHd6OX~cAXM0h2IAh)h~xHN)?S4K}ON|3rf5nd>yf<9{tX)Xgq_G{ywFC^gT+V z>Cx{;ipIynj|Gax2gC0%ipJ6JT#Lp_$^Hxby9~Y-R#bnAl7AT}MfK?WoKkR?`BHelv3YdO8@mJaRo|z4_O|tWVl1FPhort*?wXC zAz7a=zL%0diK3rI&R=2rp_KCKDf#Ot`aDW`=;uhGNMU&+$ng`#B`M_*l>V)w#OG7m zw~7)ErIcqx(I->%b13cmLg`N{IljX77E{`v0~*o(eW0}W4<-L@N<5gHzrymbQQAA5 z(%w)?`8z4~-=gTt$oVfUe+fCCh4HbJ{;#0ahZ2hR5B*HEX#5kUz3r6pwo%H9qr^2S z{V$-{_d2D%-jwt)l=h+D+ZAnJ4yAnqDCMDh718wel>7;l`l>1E&r-^>rPvqD(W(4s zO8)zl@-r#%LP}hdQeFzByj7I>YfZ8DB1+td5}!yJzgo(C8bwL3Lup@ciamBv;*Tlq z|4tci=#CgvAqK^YfholxJO4MLp&4SxmgxKR(eE6i-)&X^L7zqOe-9k64+#2g?7ko> zAn5mvRYCfLpx+Zmzd5cAG8AMOhz^J@$Z(L6AfrIgZ$ys)84IEZq7O0-1pRhA`fc(F zAci0lL5x6*LD27uPX|QHAS*xuL4rWIAi*FjK~{mF`_E92)gWs?!a&x7goCUD;ekYeM1n+tM1!mc*#Hs) z5(^RsvJqqxNIb}9kS!ovK@vc=fouoa0g?!^6C?>F86*W{7szgqRFE`~Js^8Q_JO2> z><2jj0)clBUT7e65V#c*QwJFcG6-Zahz5uzh!)5YkP#qLK&FDs25|s!1aSf(Ko}t6 zAQB*wAh5jnzvnFjC~P-FpqR1u2Sf69eO@TJ~II9$ZFN$G}Tc!IEO$4 zoCmO{h_FkPhf^pK-$72`rh=cE3Lln3>7vh`@iSi0N3c+Yu*YJghZ%klE3A}!I>di? z)0yKNxHB(8dHHtzq*GW1zcc(NVra18DG$HXUAfor9Rft0;r{ZV;}atObI>cHcL?8K z&jCB-{iOg4@g4cF9UvX{tpbAb0Eg+#e*_B( zp=LAW#?rw+(dZdlr5JZ>POgp}F2yT(>-@bps z#8mebp@QP#fsNYPAr^L`PLIaPr|PKd=n*Ak;crVYhxi^x5bu~(6b5D$g%NmNfg8kM zD;7V@7TS!zW~pT7$H&67B#oaNQ&|49WN0SE`KhDfA|78r3Y~hG(8Uh7z)l+ISZ58) z5Yoj{ST}y8&i9Xiy!>U1z3Bu|p_80|Fbn$20ZyPoX}eOQxe4ur)ruTyAvNFQw!_61 zn2zB=Ypf%sz+sB&mc3yM3m4Y5r-~e^Z4#v5EBjw zNx;_3cL~AIySths*a=41G+~_Y+yO;)8a~qHvl9zLhgtrfA=Xiezy$}A3%xkORf_Mc zDO^Bk8m@00*3(Y^-`YXV<7V5pw(K#91G zqXYp0mw1Q>?iNrvu+dKV&2Pd4Q0f8{SzjD)eru6qET?X6GkCBilQ3~IQA&M#NaH9Z-+$utROAL*B0LJh=iXtaz*g2 z2)%_uZ@W-HZsp_c9I zA@FM%1E;pHhfqUatR22f%iB76xx3>D;`6~*t{Tf+_ih`1tJmJzk`Gz!NscOi5|^z= zmaTeTQSfMaUWDo;|HQ(|@IF2(?wro{vkN(S;oyrKYTpN!Obyym@@|y=KLeTLtt6Dc znse909Y1kBRPlE0qvL~4*e=^G_SUdZ`q?p(bxNOUw+4=UI92?ZmczS#H!2>#&IzwH zb8N}E5TCnsRi@aw$7>Y6Ub?I!B{OvBa96K@NgB)aw>KJ_+b1X=;Y@gBFogRua{f_; z*i#ZYzYmwqY!csmXZNr_I+s7ZpYdf+g}g__F(5PcQ>*ihUtiN)mmc;zdeaeBR^yKoBg*>P@OIAbTXkH@-t@z< zzMEVUT1H-UEB7MIxF1=Ow)fO;8=dyf=so&{LSym39~QlhPV{%xe^z^8YUahbhGTx? zt$Et@K!WJPq$ugnFC2UMPIGz{ITbRhMz-sSwU>>meS ze%h Ny7qru9sFt+cNMHa8sb|rC?*1ua^cuC9drS;lLAM}^>c1#~46}|X{miefK zs)w!TMEx2$tf9?#_r$XSA%%Skk_S$=ZQXKa?Kc0}^icI@r_;wbZrZP8e53s6(2+a$ zlq)74811e*;AQF1TDIYo<#$^?O_}U>;L8H}$lcGE{204?T)+@1)wf#Gi3JCOFZI5B zykPTZ)6&(&;j1i_UgoZh&RDI}yCC}XnvnRAJ9qN*PYivsE7#aOYJ1f86Bel_RU%xU zPu`-GkhS`Xsq5a)0pAuGxa<#*@XcRZf9hPj9Ot`lY~1m$hgk1&51mN=-Ew$h z`SEBo*QwiPd7b$g*D@+JkUJ)QYTV~NITfzQR-dxJYLI>+B>4wtyGkFi@576CL>Zs4 zAD`Qo`DV`RQx54$5ut;=pFOaEbzjFNPQgiGo>B5L#lrUHn@^H!#*}?e3Vme1e&DY- zktN*NvT)jOdLv_ni&G@OS>=MeS z#ay|OPv4^9S$%rasHUW%!NlpK`i*(or~X_mDjAt}*X2)37+dy6hOt7HzOwrUJM$K= zESYS1KeKml6ZiUl%d_jB?LDW$OR7(>pG?00xycFv3w3)jcpGg-6KxZ}-<_6ZUSPSg zTy2DYxaXKJ{-?GKb-8PJ<rCZ7D7DZ} z`a4B!9Tg-$;@t?Hs4dsedHIGOU_Yw4aWj6Omwtkz*R#!&FIk7oJUV)_Y|OO};X$uW zC+0tlFiJUimZ73G-ZE;Ne+zx1dip+NZTFX7Y8Pnv##$|nFmr_)z`?DqN zlE+8Oy1@CHUf=aOBB%9r-`Uxv_A$XPK0gR-jh1yjwtI42ujfmTFPZgu@Fu&Nb=mRk z`(^a~1L}5a8pVt;l#MKvw7ko)=+(6U-j8JcKl^n1=v-k%&Xu!2_(@x0aWAj1QI%K%nstNC)@lzN>Aj30b}H^>z2t^?WBG9k zeSaHxDst)~Q_uK3PKb<1NFEk4%Gj^)6t~G{_{$t)*98V|k~DAD7gy>OO#Lwbl+JsX z1>9kqR3EmjHeGY^Q_EPlpR>;gB;PT(F7u-xe4NQpo22P|XAKyjBX_1U`(%OqpYvm0 zn;d-kee{CMN6%bKvSRuEh*$?7HQGcp&D(D~pI+;x;=b;Fi^R|HnxTfOzrLQ;efgnG zXQA=J#;a?7C#n4tJT?7>{8csYe(llTXXXw%oyoE&lDfW8W!R2o&!qZ(KVK04vvQjA zHjnk~0}8{MpA@WkKd;#CliAm>hohy$*ebHt4~t_alvszp`aDHtBy%!n+{f{^*X`Q5 zV#<{ZNxu)*#RuH^#<74+V}aJQyb%)aAMF10Q}`r5(L|0mkf}auU!M?<6Xi9AS!x!^ z#d5b32Ip-b9b)^N=DqIHi;>aEUCqaD7s*SR4cq^F^ROVbZTho) z*37zlc+!zshe`~IXvz~Sf$2De>?mvXqAh$S{QrdyGIgvzQK1jnRh?t z|D>N8f(t>u&4ckvF4*IFBy7P|OfQ?Fb|0!SLuu}anpw8s=c#Asz}Pvg`f zRx1vBd5RA?yWpI+OO4GKNxMMB3j60S&d)6#Jddb9JH$BQbyQU0Hq%yHi@vdw*fZw6 zX(&6C?Q_>P?);h+ZKKn@V;!XBs~pReBqE2--o8ZfPkV66J?n$)w^!J8ZCB04EUFkY z!bHROR`!_J-nplHSsq*byzlfx_O&SMpd&{t97A0>$?psIUeGX@!u)Y|yAk7TNu_wv zE?ezydzZxYR~q+RKHYHseHH1l_H*aeHp(j#ldO!9ij$Bo}+x)J@&Ka9OUTKdoeA+7A#~}75JM*A!iT|a{ zpwnSz2RUkc?6|8r_s;ogo1RbqBeA}=g}3;QIqT!RQ_CZ?7yFK#z1TiHOQp~2NuJdq z(az_e414|j$^76UO`GmY6@A*D7029h{D#E+gqWZH=YPNUk_a=d^DlhhIcxmEwDIAM zzYgqizpz#f4+`R*zxAtGPzXSVh^qCxn@?< zp`J%-tXiM5Y+{ebZwBt=GyP5RNBVrF)nEQ#%JvkY&0BpZsUJL%5UxuTWoy7316)w;PZ z8E3Dv#%jsA82(IMyXLtLiOC%Z91&@F3v?ctD#hq>K_9LtNnTVTi`X}$* ztm_BVp1zy0Hu|{ayo|JXsiiY(-&9G>X?c5j9Q{ks#kaRg%ygc4-&=O>`okaoNsC_x zZho$?ZIJ2H4Sox%A}56{tBZFjd@dEV_j&V#p_LytjXGiCRQBnZn@m$gi_*o~z@xXv z+w6Sbbf-eycXUhRe&eTO;;u}|HCK9QwK$yph&{};wR(ZyJeph8s)fB~&73>p#)iCs z*{`)H8@q?tr0tsL(l0())i$Tv%9y@;%)8T}%*0hEdmXyIFt7KbS#w)rn)6;(4jQzH zIHVq9(mp2Ul=`YcW1E*ajs7~G^?q7^`K{HH@3VWy);+BGsu@<*&p1n=Y)-7r@=C{p zH)Yyw&5WXd-Qy0wvn%3L<*A@~YTT#gq5U4Po=h}RKH3}AUxFTY zTk_x__igQ)x&J}8lit#{jZV#jUKA{>9M?}Ru&>19u@7gD9kVR_`LVo`md!(s z{;sy#aP;Gqi|0xcUH4~yvl_o=>*Vu$ZX~DmySc|yqeiDh(s;wL5X}clTUYgRV}I6N zYNS_T(rLIMnbWC@zc{_CE zPwPfj|AA!%FH%i%o@}1AX~Sc=^$Yhp`zt9CtxQ(rFO|neUnBL5GH>tM6}iH4e*X{7 zDz7dm4;WEqk?SaNZOZ01HbkLAft`N2>_n@Fo2U3^^{pK8(dnNfqu*#0l+0h6^I)0l zq=$}7xn(C7JG;kU?q5G-(C(ry2FHxm66T$A%zT?x;;AfFaB1qqx~XsbN7fa$6UUco zMq68%XP+N?=2iBg_5K6wJ-3A(YyLejYQf-jC!cK1a43Jg{-Fj(e_PIx!7~ShCT}sR z=ykTd?;@J~sv=ttH|eV}Px~C*U-zfi0gWib`ceKn%xFn1pAW9I2!HAP(_>P`4J#7` zl|kFn+20Q>K4=@Ex_@|EU-Koc?fV)Y8f8g~52cAbUGHrrkzey7S!+?QgVM3cPoPYuW78ZZ8J(-Vug z6%&rE+9w%UWv+O6mX2?i*`zD|wRwQp5ZCNDWwW#rH!I?DqxF*o ztE{+M5o4CzPdfGU;!N`($r&*>C!9>I*j6y&w4w@&cc=B{*^@7o-fXsuU2x^iCUxEC zCJ$NW>nq`G%bsZ?RB_u3P0Cov7m4n5hZEHh@)pM0-(3b$X6w%)C@=hrNGc@+)G z2NS2WoxJ9y`CpVjgk5?(Qe!YBb zqg|}ew(q{9cB^K)MlX&nlDu%(-+und(}Ru_EdKmHBu=T1M*GwLGj%H3M&2`hdrt>No_iP{o&KAo`(iA+T%@sRLzM@lA75N-sE-J`kwNE(ogHx z>P38}T}`ZsalUtAE;s7%&y{Z;-BpvX`(b5rV?xD-Q{gYF@{RX=eP*+At?QU~%#W+T z8Ga1imDp1EHPcwra)q6y$+EH+je8a!!q zvD4!*TW`7t_WFmDqBt>^H#wy8EX%4NG37zZu<1knFX>JE@!9`qz%=`N_Foc}j_;jX zzGUl9IZI~fcb(kjmMb(q&cFM~df|PK*2ZWT?~LZ>;R>GX<;ESC*nX*3;_UuIj?;)8 z=9gA%KgpPrYNPinc748YD)-gBNtwx~ni%qr&LjM3QJ=zB9Av#o50s0L zl!yo(8mWKu(=es$2Ah;;o*0{^a^LL1{v$1+FU6Xgj?37!J6P4LIvy|!-Ibxby{_<9 z``d*_*vC$2Z>cD{J*`02{YaXP`O&>rQEN*M>~?-~dXxB+^L37CBOBAVDowuls{VVd zR0VrgQ_=f}Kh!SuZF0z!zhw0NpNeG9(173ZdB2}sKC;1O)|6*2V^)W+*%ogbvAbsD zkC>x|dJf4PBelz~j>#C9+*AG|Ib!RLt3M(t#@=2#dQ)h>@eMOVM}=vA_FizoS={P< z+r`$JYwinMterNx{=6I!Ef!cdboDQL?T5~fxzBV~&nQ|_AFn}VUHL6uyY=|fDH9_< zEE>dIx_^;I%@$R=*HY7t#Ad2q-gUppA-Vn3UN~h(*Nh$C)c^S5{h>jRZJEuHu@6QZ z{5pP*c4pefbBQbW_ua6qcyT7vr+x4k9eUhx<3T#6nR~`tb4!0u4AXfO>FBGOx_|nn z$IjF2XHVEScjobQ*YI--8wXDsvH8B7cbPucPeVM?(*H0xn)s|%Gv?T4~gsUx4!Fq_s>A_ z@6qSaELW}NC|$pP{6=XsZ}$3~Pa4&3Ra{Kgm6(1jvv}~EYpIRCgWna*^Pkr*EjDw& zx*s>0(}wwRvo@UC5|Z3hyKh_GY0vY+5A^Yo$d9y|;bd~`fV-rOz2DV4LnfE+GW>F{ zV#RT_m=?8Ao0YU-=3{@e)temaZMO{@_s-*0d)akOp2HGxd+D)hpFED-G)hfsI==Pt}GprK1;t>ff?6y9o{Xy^?bZ(L2lHB6vbByk44C3&6&66hK5$I zYaqMfaP*s@nsOD=rLCOz`Tf7V@pZm)X-;P2jluoL9g8_NNpI7WU#XFj>i(HOd;1gb zYQFwViDqp~{@S?Ddd{o<4_&=#^A(%>UBC9|C*eHYOMKesch7w_*;g;MG3|f9%vGK> z$K!~hPR^>gC+n0|q8C2>e8%%4u(NFZUbpIZPtqw~UF0c683`J>hQ()=N*5F4`lV&}*7nlx)q>BJ+E89~O&; zau%%L9T;}8{f+)-Z~dygC2f1IHT0qFxH&*SSMuY%UA^~xT~Syg9p!Uq!BEYia)slz z=bURwxie6$;_-9GFWDFVByUYi(mrc%oxE+@(-k?Zrj_1b)@*7ee`M(OZ0XnApIu;& z4jKOS;kvO=VZ$=NEP0{yfVNU)^MGF0$bRykN>w0zW-$>CR)_$=cPev{N{andGInCRjN0?_nQ7q-k1wT;jPVJ7_w91X&&7s{ zU(XfiOEDZ$+pj%cX#QjUnWQBl_7*h(0i`j&9)5Us{bO02_|j3SW)_QThv;7Ht9SKP z#Bs)x+y!^yWS;M}?>ArK_V~lIU-Y}S)_uz|!^$!vl``7O4>Pxo(bqb@F8j1gz>T-w zMU2N470YRJUBv%{<%X?Z|McP}>$p*erGMz^c8e=g%kGZzT3YsM)B?>DT9LJ0hMKsc5D;d6LVh4V8%hS z$mzQ4L${obURovd`GUN?WU=-0LGGdc<2ctpp4K~S?JGA$!Z3FItkfg2trbDn!hCjp zd{fqEIV*8+P24|g)@{DEuEl+r#W>nnt@F$6+8-w`88dBZ_SPQ@b=0-KXT5IyeRgnl zB=foLCWi>3hdDj6Whj z?{!{Z?U2p8GxS#-*R9)LZ!}s%LGj{nv6n$PKT=$#^bT?ydd_ICdfTH|UHaWK4o?m) mzxC=x^T0&+l-SWVz4QEs9rjS+u literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d139eea --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +numpy +pandas +requests \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..976fd7d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,6 @@ +edition = "2021" +max_width = 100 +use_small_heuristics = "Max" +# Note: imports_granularity and group_imports require nightly Rust +# imports_granularity = "Module" +# group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/src/core/error.rs b/src/core/error.rs new file mode 100644 index 0000000..8f1815d --- /dev/null +++ b/src/core/error.rs @@ -0,0 +1,80 @@ +//! Error types for RaptorBT. + +use thiserror::Error; + +/// Result type alias for RaptorBT operations. +pub type Result = std::result::Result; + +/// Error types for the backtesting engine. +#[derive(Error, Debug)] +pub enum RaptorError { + /// Data length mismatch between arrays. + #[error("Data length mismatch: expected {expected}, got {actual}")] + LengthMismatch { expected: usize, actual: usize }, + + /// Invalid parameter value. + #[error("Invalid parameter: {message}")] + InvalidParameter { message: String }, + + /// Insufficient data for calculation. + #[error("Insufficient data: need at least {required} elements, got {available}")] + InsufficientData { required: usize, available: usize }, + + /// Invalid configuration. + #[error("Invalid configuration: {message}")] + InvalidConfig { message: String }, + + /// Division by zero error. + #[error("Division by zero in {context}")] + DivisionByZero { context: String }, + + /// Empty data error. + #[error("Empty data provided for {context}")] + EmptyData { context: String }, + + /// Invalid index access. + #[error("Index {index} out of bounds for length {length}")] + IndexOutOfBounds { index: usize, length: usize }, + + /// Python conversion error. + #[error("Python conversion error: {message}")] + PythonError { message: String }, +} + +impl RaptorError { + /// Create a length mismatch error. + pub fn length_mismatch(expected: usize, actual: usize) -> Self { + Self::LengthMismatch { expected, actual } + } + + /// Create an invalid parameter error. + pub fn invalid_parameter(message: impl Into) -> Self { + Self::InvalidParameter { message: message.into() } + } + + /// Create an insufficient data error. + pub fn insufficient_data(required: usize, available: usize) -> Self { + Self::InsufficientData { required, available } + } + + /// Create an invalid config error. + pub fn invalid_config(message: impl Into) -> Self { + Self::InvalidConfig { message: message.into() } + } + + /// Create a division by zero error. + pub fn division_by_zero(context: impl Into) -> Self { + Self::DivisionByZero { context: context.into() } + } + + /// Create an empty data error. + pub fn empty_data(context: impl Into) -> Self { + Self::EmptyData { context: context.into() } + } +} + +impl From for pyo3::PyErr { + fn from(err: RaptorError) -> pyo3::PyErr { + pyo3::exceptions::PyValueError::new_err(err.to_string()) + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..653cb7a --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,11 @@ +//! Core types and utilities for RaptorBT. + +pub mod error; +pub mod session; +pub mod timeseries; +pub mod types; + +pub use error::{RaptorError, Result}; +pub use session::{SessionConfig, SessionTracker}; +pub use timeseries::TimeSeries; +pub use types::*; diff --git a/src/core/session.rs b/src/core/session.rs new file mode 100644 index 0000000..76f648a --- /dev/null +++ b/src/core/session.rs @@ -0,0 +1,397 @@ +//! Session tracking for intraday strategies. +//! +//! Handles: +//! - Session boundary detection (market open/close) +//! - Squareoff time enforcement +//! - Session high/low tracking for ORB and session-based indicators +//! - Timezone handling for IST (India Standard Time) + +use serde::{Deserialize, Serialize}; + +/// Session configuration for trading hours. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionConfig { + /// Market open hour (24-hour format). + pub market_open_hour: u32, + /// Market open minute. + pub market_open_minute: u32, + /// Market close hour (24-hour format). + pub market_close_hour: u32, + /// Market close minute. + pub market_close_minute: u32, + /// Squareoff minutes before market close. + pub squareoff_minutes_before_close: u32, + /// Timezone offset in hours from UTC (5 for IST = UTC+5:30). + pub timezone_offset_hours: i32, + /// Timezone offset minutes (30 for IST). + pub timezone_offset_minutes: i32, +} + +impl Default for SessionConfig { + fn default() -> Self { + // Default: NSE equity session (9:15 - 15:30 IST, squareoff at 15:25) + Self { + market_open_hour: 9, + market_open_minute: 15, + market_close_hour: 15, + market_close_minute: 30, + squareoff_minutes_before_close: 5, + timezone_offset_hours: 5, + timezone_offset_minutes: 30, + } + } +} + +impl SessionConfig { + /// Create NSE equity session config (9:15 - 15:30). + pub fn nse_equity() -> Self { + Self::default() + } + + /// Create MCX commodity session config (9:00 - 23:30). + pub fn mcx_commodity() -> Self { + Self { + market_open_hour: 9, + market_open_minute: 0, + market_close_hour: 23, + market_close_minute: 30, + squareoff_minutes_before_close: 5, + timezone_offset_hours: 5, + timezone_offset_minutes: 30, + } + } + + /// Create CDS currency session config (9:00 - 17:00). + pub fn cds_currency() -> Self { + Self { + market_open_hour: 9, + market_open_minute: 0, + market_close_hour: 17, + market_close_minute: 0, + squareoff_minutes_before_close: 5, + timezone_offset_hours: 5, + timezone_offset_minutes: 30, + } + } + + /// Get market open time in minutes from midnight. + pub fn market_open_minutes(&self) -> u32 { + self.market_open_hour * 60 + self.market_open_minute + } + + /// Get market close time in minutes from midnight. + pub fn market_close_minutes(&self) -> u32 { + self.market_close_hour * 60 + self.market_close_minute + } + + /// Get squareoff time in minutes from midnight. + pub fn squareoff_minutes(&self) -> u32 { + self.market_close_minutes().saturating_sub(self.squareoff_minutes_before_close) + } + + /// Get timezone offset in seconds. + pub fn timezone_offset_seconds(&self) -> i64 { + (self.timezone_offset_hours as i64 * 3600) + (self.timezone_offset_minutes as i64 * 60) + } +} + +/// Session tracker for managing intraday session state. +#[derive(Debug, Clone)] +pub struct SessionTracker { + config: SessionConfig, + /// Current session date (days since epoch in local timezone). + current_session_date: i64, + /// Session high price. + session_high: f64, + /// Session low price. + session_low: f64, + /// Session open price. + session_open: f64, + /// Bar index at session start. + session_start_idx: usize, + /// Whether we're currently in a trading session. + in_session: bool, + /// Whether squareoff has been triggered today. + squareoff_triggered: bool, +} + +impl SessionTracker { + /// Create a new session tracker. + pub fn new(config: SessionConfig) -> Self { + Self { + config, + current_session_date: -1, + session_high: f64::NEG_INFINITY, + session_low: f64::INFINITY, + session_open: 0.0, + session_start_idx: 0, + in_session: false, + squareoff_triggered: false, + } + } + + /// Convert nanosecond timestamp to local time components. + fn timestamp_to_local(&self, timestamp_ns: i64) -> (i64, u32, u32, u32) { + // Convert to seconds + let timestamp_s = timestamp_ns / 1_000_000_000; + + // Apply timezone offset + let local_s = timestamp_s + self.config.timezone_offset_seconds(); + + // Calculate date (days since epoch) + let days = local_s / 86400; + + // Calculate time within day + let time_in_day = (local_s % 86400) as u32; + let hours = time_in_day / 3600; + let minutes = (time_in_day % 3600) / 60; + let seconds = time_in_day % 60; + + (days, hours, minutes, seconds) + } + + /// Get minutes from midnight for a timestamp. + fn get_minutes_from_midnight(&self, timestamp_ns: i64) -> u32 { + let (_, hours, minutes, _) = self.timestamp_to_local(timestamp_ns); + hours * 60 + minutes + } + + /// Check if timestamp is within trading hours. + pub fn is_within_trading_hours(&self, timestamp_ns: i64) -> bool { + let minutes = self.get_minutes_from_midnight(timestamp_ns); + minutes >= self.config.market_open_minutes() && minutes < self.config.market_close_minutes() + } + + /// Check if it's squareoff time. + pub fn is_squareoff_time(&self, timestamp_ns: i64) -> bool { + let minutes = self.get_minutes_from_midnight(timestamp_ns); + minutes >= self.config.squareoff_minutes() + } + + /// Check if this bar starts a new session. + pub fn is_session_start(&self, prev_ts_ns: i64, curr_ts_ns: i64) -> bool { + let (prev_date, prev_h, prev_m, _) = self.timestamp_to_local(prev_ts_ns); + let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns); + + // New day + if curr_date != prev_date { + let curr_minutes = curr_h * 60 + curr_m; + return curr_minutes >= self.config.market_open_minutes(); + } + + // Same day, but crossed market open + let prev_minutes = prev_h * 60 + prev_m; + let curr_minutes = curr_h * 60 + curr_m; + + prev_minutes < self.config.market_open_minutes() + && curr_minutes >= self.config.market_open_minutes() + } + + /// Check if this bar ends the session. + pub fn is_session_end(&self, curr_ts_ns: i64, next_ts_ns: Option) -> bool { + let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns); + let curr_minutes = curr_h * 60 + curr_m; + + // At or past market close + if curr_minutes >= self.config.market_close_minutes() { + return true; + } + + // Check if next bar is in a new session + if let Some(next_ts) = next_ts_ns { + let (next_date, _, _, _) = self.timestamp_to_local(next_ts); + if next_date != curr_date { + return true; + } + } + + false + } + + /// Update session state for a new bar. + /// + /// Returns tuple of (is_new_session, is_squareoff_time, is_session_end). + pub fn update( + &mut self, + idx: usize, + timestamp_ns: i64, + open: f64, + high: f64, + low: f64, + _close: f64, + prev_timestamp_ns: Option, + next_timestamp_ns: Option, + ) -> (bool, bool, bool) { + let (date, hours, minutes, _) = self.timestamp_to_local(timestamp_ns); + let time_minutes = hours * 60 + minutes; + + // Check for new session + let is_new_session = if self.current_session_date != date { + // New date - check if within trading hours + if time_minutes >= self.config.market_open_minutes() + && time_minutes < self.config.market_close_minutes() + { + self.reset_session(idx, date, open); + true + } else { + false + } + } else if let Some(prev_ts) = prev_timestamp_ns { + if self.is_session_start(prev_ts, timestamp_ns) { + self.reset_session(idx, date, open); + true + } else { + false + } + } else { + // First bar - start session if within hours + if time_minutes >= self.config.market_open_minutes() + && time_minutes < self.config.market_close_minutes() + { + self.reset_session(idx, date, open); + true + } else { + false + } + }; + + // Update session high/low + if self.in_session { + if high > self.session_high { + self.session_high = high; + } + if low < self.session_low { + self.session_low = low; + } + } + + // Check squareoff time + let is_squareoff = + if time_minutes >= self.config.squareoff_minutes() && !self.squareoff_triggered { + self.squareoff_triggered = true; + self.in_session + } else { + false + }; + + // Check session end + let is_session_end = self.is_session_end(timestamp_ns, next_timestamp_ns); + if is_session_end { + self.in_session = false; + } + + (is_new_session, is_squareoff, is_session_end) + } + + /// Reset session state for a new trading day. + fn reset_session(&mut self, idx: usize, date: i64, open_price: f64) { + self.current_session_date = date; + self.session_start_idx = idx; + self.session_open = open_price; + self.session_high = open_price; + self.session_low = open_price; + self.in_session = true; + self.squareoff_triggered = false; + } + + /// Get current session high. + pub fn session_high(&self) -> f64 { + self.session_high + } + + /// Get current session low. + pub fn session_low(&self) -> f64 { + self.session_low + } + + /// Get current session open. + pub fn session_open(&self) -> f64 { + self.session_open + } + + /// Get session start index. + pub fn session_start_idx(&self) -> usize { + self.session_start_idx + } + + /// Check if currently in a trading session. + pub fn in_session(&self) -> bool { + self.in_session + } + + /// Get opening range (high - low) for the session. + pub fn opening_range(&self) -> f64 { + self.session_high - self.session_low + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_timestamp(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 { + // Simplified: calculate seconds from 1970-01-01 and convert to nanoseconds + // This is approximate for testing + let days_since_epoch = (year - 1970) as i64 * 365 + (month - 1) as i64 * 30 + day as i64; + let seconds = days_since_epoch * 86400 + hour as i64 * 3600 + minute as i64 * 60; + // Subtract IST offset to get UTC + let utc_seconds = seconds - (5 * 3600 + 30 * 60); + utc_seconds * 1_000_000_000 + } + + #[test] + fn test_session_config_defaults() { + let config = SessionConfig::default(); + assert_eq!(config.market_open_hour, 9); + assert_eq!(config.market_open_minute, 15); + assert_eq!(config.market_close_hour, 15); + assert_eq!(config.market_close_minute, 30); + assert_eq!(config.squareoff_minutes_before_close, 5); + } + + #[test] + fn test_squareoff_minutes() { + let config = SessionConfig::default(); + // 15:30 - 5 minutes = 15:25 = 925 minutes + assert_eq!(config.squareoff_minutes(), 925); + } + + #[test] + fn test_mcx_session() { + let config = SessionConfig::mcx_commodity(); + assert_eq!(config.market_open_hour, 9); + assert_eq!(config.market_close_hour, 23); + assert_eq!(config.market_close_minute, 30); + } + + #[test] + fn test_session_tracker_new_session() { + let config = SessionConfig::default(); + let mut tracker = SessionTracker::new(config); + + // Simulate market open at 9:15 IST + let ts = make_timestamp(2024, 1, 15, 9, 15); + let (is_new, _, _) = tracker.update(0, ts, 100.0, 101.0, 99.0, 100.5, None, None); + + assert!(is_new); + assert!(tracker.in_session()); + assert_eq!(tracker.session_open(), 100.0); + } + + #[test] + fn test_session_high_low() { + let config = SessionConfig::default(); + let mut tracker = SessionTracker::new(config); + + // First bar + let ts1 = make_timestamp(2024, 1, 15, 9, 15); + tracker.update(0, ts1, 100.0, 105.0, 95.0, 102.0, None, None); + + // Second bar + let ts2 = make_timestamp(2024, 1, 15, 9, 30); + tracker.update(1, ts2, 102.0, 110.0, 100.0, 108.0, Some(ts1), None); + + assert_eq!(tracker.session_high(), 110.0); + assert_eq!(tracker.session_low(), 95.0); + } +} diff --git a/src/core/timeseries.rs b/src/core/timeseries.rs new file mode 100644 index 0000000..655cbea --- /dev/null +++ b/src/core/timeseries.rs @@ -0,0 +1,301 @@ +//! Time-indexed array wrapper for efficient operations. + +use super::types::Timestamp; + +/// A time-indexed series of values. +#[derive(Debug, Clone)] +pub struct TimeSeries { + /// Timestamps for each value. + pub timestamps: Vec, + /// Values. + pub values: Vec, +} + +impl TimeSeries { + /// Create a new time series. + pub fn new(timestamps: Vec, values: Vec) -> Self { + debug_assert_eq!(timestamps.len(), values.len()); + Self { timestamps, values } + } + + /// Create from values only (no timestamps). + pub fn from_values(values: Vec) -> Self { + let timestamps = (0..values.len() as i64).collect(); + Self { timestamps, values } + } + + /// Get the length. + #[inline] + pub fn len(&self) -> usize { + self.values.len() + } + + /// Check if empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// Get value at index. + #[inline] + pub fn get(&self, index: usize) -> Option<&T> { + self.values.get(index) + } + + /// Get timestamp at index. + #[inline] + pub fn get_timestamp(&self, index: usize) -> Option { + self.timestamps.get(index).copied() + } + + /// Get slice of values. + pub fn slice(&self, start: usize, end: usize) -> Self { + Self { + timestamps: self.timestamps[start..end].to_vec(), + values: self.values[start..end].to_vec(), + } + } + + /// Map values to a new type. + pub fn map(&self, f: F) -> TimeSeries + where + F: Fn(&T) -> U, + { + TimeSeries { + timestamps: self.timestamps.clone(), + values: self.values.iter().map(f).collect(), + } + } + + /// Iterator over (timestamp, value) pairs. + pub fn iter(&self) -> impl Iterator { + self.timestamps.iter().copied().zip(self.values.iter()) + } +} + +impl TimeSeries { + /// Create with default values. + pub fn with_default(timestamps: Vec) -> Self { + let len = timestamps.len(); + Self { timestamps, values: vec![T::default(); len] } + } +} + +impl TimeSeries { + /// Create a series filled with NaN. + pub fn with_nan(len: usize) -> Self { + Self { timestamps: (0..len as i64).collect(), values: vec![f64::NAN; len] } + } + + /// Calculate sum of all values. + pub fn sum(&self) -> f64 { + self.values.iter().filter(|v| !v.is_nan()).sum() + } + + /// Calculate mean of all values. + pub fn mean(&self) -> f64 { + let valid: Vec<_> = self.values.iter().filter(|v| !v.is_nan()).collect(); + if valid.is_empty() { + return f64::NAN; + } + valid.iter().copied().sum::() / valid.len() as f64 + } + + /// Calculate standard deviation. + pub fn std(&self) -> f64 { + let mean = self.mean(); + if mean.is_nan() { + return f64::NAN; + } + let valid: Vec<_> = self.values.iter().filter(|v| !v.is_nan()).collect(); + if valid.len() < 2 { + return f64::NAN; + } + let variance = + valid.iter().map(|v| (*v - mean).powi(2)).sum::() / (valid.len() - 1) as f64; + variance.sqrt() + } + + /// Get minimum value. + pub fn min(&self) -> f64 { + self.values.iter().filter(|v| !v.is_nan()).copied().fold(f64::INFINITY, f64::min) + } + + /// Get maximum value. + pub fn max(&self) -> f64 { + self.values.iter().filter(|v| !v.is_nan()).copied().fold(f64::NEG_INFINITY, f64::max) + } + + /// Shift values by n positions (positive = shift forward, fill with NaN). + pub fn shift(&self, n: isize) -> Self { + let len = self.values.len(); + let mut result = vec![f64::NAN; len]; + + if n >= 0 { + let n = n as usize; + if n < len { + for i in n..len { + result[i] = self.values[i - n]; + } + } + } else { + let n = (-n) as usize; + if n < len { + for i in 0..len - n { + result[i] = self.values[i + n]; + } + } + } + + Self { timestamps: self.timestamps.clone(), values: result } + } + + /// Calculate difference from previous value. + pub fn diff(&self) -> Self { + let mut result = vec![f64::NAN; self.values.len()]; + for i in 1..self.values.len() { + if !self.values[i].is_nan() && !self.values[i - 1].is_nan() { + result[i] = self.values[i] - self.values[i - 1]; + } + } + Self { timestamps: self.timestamps.clone(), values: result } + } + + /// Calculate percentage change from previous value. + pub fn pct_change(&self) -> Self { + let mut result = vec![f64::NAN; self.values.len()]; + for i in 1..self.values.len() { + if !self.values[i].is_nan() && !self.values[i - 1].is_nan() && self.values[i - 1] != 0.0 + { + result[i] = (self.values[i] - self.values[i - 1]) / self.values[i - 1]; + } + } + Self { timestamps: self.timestamps.clone(), values: result } + } + + /// Apply rolling window function. + pub fn rolling(&self, window: usize, f: F) -> Self + where + F: Fn(&[f64]) -> f64, + { + let mut result = vec![f64::NAN; self.values.len()]; + if window == 0 || window > self.values.len() { + return Self { timestamps: self.timestamps.clone(), values: result }; + } + + for i in (window - 1)..self.values.len() { + let slice = &self.values[i + 1 - window..=i]; + result[i] = f(slice); + } + + Self { timestamps: self.timestamps.clone(), values: result } + } + + /// Calculate rolling sum. + pub fn rolling_sum(&self, window: usize) -> Self { + self.rolling(window, |slice| slice.iter().sum()) + } + + /// Calculate rolling mean. + pub fn rolling_mean(&self, window: usize) -> Self { + self.rolling(window, |slice| slice.iter().sum::() / slice.len() as f64) + } + + /// Calculate rolling standard deviation. + pub fn rolling_std(&self, window: usize) -> Self { + self.rolling(window, |slice| { + let mean = slice.iter().sum::() / slice.len() as f64; + let variance = + slice.iter().map(|v| (v - mean).powi(2)).sum::() / (slice.len() - 1) as f64; + variance.sqrt() + }) + } + + /// Calculate rolling maximum. + pub fn rolling_max(&self, window: usize) -> Self { + self.rolling(window, |slice| slice.iter().copied().fold(f64::NEG_INFINITY, f64::max)) + } + + /// Calculate rolling minimum. + pub fn rolling_min(&self, window: usize) -> Self { + self.rolling(window, |slice| slice.iter().copied().fold(f64::INFINITY, f64::min)) + } +} + +impl TimeSeries { + /// Count true values. + pub fn count_true(&self) -> usize { + self.values.iter().filter(|&&v| v).count() + } + + /// Get indices of true values. + pub fn true_indices(&self) -> Vec { + self.values + .iter() + .enumerate() + .filter_map(|(i, &v)| if v { Some(i) } else { None }) + .collect() + } + + /// Logical AND with another series. + pub fn and(&self, other: &Self) -> Self { + debug_assert_eq!(self.len(), other.len()); + Self { + timestamps: self.timestamps.clone(), + values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a && b).collect(), + } + } + + /// Logical OR with another series. + pub fn or(&self, other: &Self) -> Self { + debug_assert_eq!(self.len(), other.len()); + Self { + timestamps: self.timestamps.clone(), + values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a || b).collect(), + } + } + + /// Logical NOT. + pub fn not(&self) -> Self { + Self { + timestamps: self.timestamps.clone(), + values: self.values.iter().map(|&v| !v).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rolling_mean() { + let ts = TimeSeries::from_values(vec![1.0, 2.0, 3.0, 4.0, 5.0]); + let result = ts.rolling_mean(3); + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); + assert!((result.values[2] - 2.0).abs() < 1e-10); + assert!((result.values[3] - 3.0).abs() < 1e-10); + assert!((result.values[4] - 4.0).abs() < 1e-10); + } + + #[test] + fn test_shift() { + let ts = TimeSeries::from_values(vec![1.0, 2.0, 3.0, 4.0, 5.0]); + let shifted = ts.shift(2); + assert!(shifted.values[0].is_nan()); + assert!(shifted.values[1].is_nan()); + assert!((shifted.values[2] - 1.0).abs() < 1e-10); + assert!((shifted.values[3] - 2.0).abs() < 1e-10); + assert!((shifted.values[4] - 3.0).abs() < 1e-10); + } + + #[test] + fn test_pct_change() { + let ts = TimeSeries::from_values(vec![100.0, 110.0, 99.0]); + let pct = ts.pct_change(); + assert!(pct.values[0].is_nan()); + assert!((pct.values[1] - 0.1).abs() < 1e-10); + assert!((pct.values[2] - (-0.1)).abs() < 1e-10); + } +} diff --git a/src/core/types.rs b/src/core/types.rs new file mode 100644 index 0000000..ac6b6cc --- /dev/null +++ b/src/core/types.rs @@ -0,0 +1,595 @@ +//! Core data types for RaptorBT. + +use serde::{Deserialize, Serialize}; + +/// Type alias for price values. +pub type Price = f64; + +/// Type alias for timestamp values (nanoseconds since epoch). +pub type Timestamp = i64; + +/// Trading direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(i8)] +pub enum Direction { + /// Long position (buy to open, sell to close). + Long = 1, + /// Short position (sell to open, buy to close). + Short = -1, +} + +impl Direction { + /// Convert direction to multiplier for P&L calculations. + #[inline] + pub fn multiplier(self) -> f64 { + self as i8 as f64 + } + + /// Create direction from integer. + pub fn from_int(value: i32) -> Option { + match value { + 1 => Some(Direction::Long), + -1 => Some(Direction::Short), + _ => None, + } + } +} + +impl Default for Direction { + fn default() -> Self { + Direction::Long + } +} + +/// OHLCV data for a single bar. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct OhlcvBar { + pub timestamp: Timestamp, + pub open: Price, + pub high: Price, + pub low: Price, + pub close: Price, + pub volume: f64, +} + +/// OHLCV data series. +#[derive(Debug, Clone)] +pub struct OhlcvData { + pub timestamps: Vec, + pub open: Vec, + pub high: Vec, + pub low: Vec, + pub close: Vec, + pub volume: Vec, +} + +impl OhlcvData { + /// Create new OHLCV data from vectors. + pub fn new( + timestamps: Vec, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> Self { + Self { timestamps, open, high, low, close, volume } + } + + /// Get the number of bars. + #[inline] + pub fn len(&self) -> usize { + self.close.len() + } + + /// Check if empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.close.is_empty() + } + + /// Get a single bar at index. + pub fn get_bar(&self, index: usize) -> Option { + if index >= self.len() { + return None; + } + Some(OhlcvBar { + timestamp: self.timestamps[index], + open: self.open[index], + high: self.high[index], + low: self.low[index], + close: self.close[index], + volume: self.volume[index], + }) + } +} + +/// Raw tick data series for tick-level backtesting. +/// +/// All fields are parallel arrays of length N (one entry per tick). +/// `buy_qty_delta` and `sell_qty_delta` must be per-tick deltas, not +/// cumulative session totals — callers are responsible for converting +/// Zerodha-style running sums before passing them here. +#[derive(Debug, Clone)] +pub struct TickData { + /// Nanoseconds-since-epoch timestamp for each tick. + pub timestamps: Vec, + /// Last traded price at each tick. + pub ltp: Vec, + /// Best bid price at each tick (0.0 if unavailable). + pub bid: Vec, + /// Best ask price at each tick (0.0 if unavailable). + pub ask: Vec, + /// Per-tick buy quantity delta (not cumulative). + pub buy_qty_delta: Vec, + /// Per-tick sell quantity delta (not cumulative). + pub sell_qty_delta: Vec, + /// Open interest at each tick (0 if unavailable). + pub oi: Vec, +} + +impl TickData { + /// Number of ticks. + #[inline] + pub fn len(&self) -> usize { + self.ltp.len() + } + + /// Whether the series is empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.ltp.is_empty() + } +} + +/// Compiled trading signals from strategy. +#[derive(Debug, Clone)] +pub struct CompiledSignals { + /// Symbol identifier. + pub symbol: String, + /// Entry signals (true = enter position). + pub entries: Vec, + /// Exit signals (true = exit position). + pub exits: Vec, + /// Optional position sizes (fraction of capital). + pub position_sizes: Option>, + /// Trading direction. + pub direction: Direction, + /// Weight for portfolio allocation. + pub weight: f64, +} + +impl CompiledSignals { + /// Create new compiled signals. + pub fn new( + symbol: String, + entries: Vec, + exits: Vec, + direction: Direction, + weight: f64, + ) -> Self { + Self { symbol, entries, exits, position_sizes: None, direction, weight } + } + + /// Set position sizes. + pub fn with_position_sizes(mut self, sizes: Vec) -> Self { + self.position_sizes = Some(sizes); + self + } + + /// Get the number of bars. + #[inline] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Check if empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// A single executed trade. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Trade { + /// Trade identifier. + pub id: u64, + /// Symbol traded. + pub symbol: String, + /// Entry bar index. + pub entry_idx: usize, + /// Exit bar index. + pub exit_idx: usize, + /// Entry price. + pub entry_price: Price, + /// Exit price. + pub exit_price: Price, + /// Position size (number of shares/contracts). + pub size: f64, + /// Trading direction. + pub direction: Direction, + /// Realized profit/loss. + pub pnl: f64, + /// Return percentage. + pub return_pct: f64, + /// Entry timestamp. + pub entry_time: Timestamp, + /// Exit timestamp. + pub exit_time: Timestamp, + /// Fees paid. + pub fees: f64, + /// Exit reason. + pub exit_reason: ExitReason, +} + +impl Trade { + /// Check if trade was profitable. + #[inline] + pub fn is_winning(&self) -> bool { + self.pnl > 0.0 + } + + /// Get holding period in bars. + #[inline] + pub fn holding_period(&self) -> usize { + self.exit_idx - self.entry_idx + } +} + +/// Reason for exiting a trade. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ExitReason { + /// Normal exit signal. + Signal, + /// Stop-loss hit. + StopLoss, + /// Take-profit hit. + TakeProfit, + /// Trailing stop hit. + TrailingStop, + /// End of data. + EndOfData, + /// Option expiry settlement. + Settlement, + /// Max hold time exceeded (tick backtest). + TimeExit, +} + +/// Backtest configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacktestConfig { + /// Initial capital. + pub initial_capital: f64, + /// Transaction fees as fraction (0.001 = 0.1%). + pub fees: f64, + /// Slippage as fraction. + pub slippage: f64, + /// Stop-loss configuration. + pub stop: StopConfig, + /// Take-profit configuration. + pub target: TargetConfig, + /// Whether to execute on bar close. + pub upon_bar_close: bool, +} + +impl Default for BacktestConfig { + fn default() -> Self { + Self { + initial_capital: 100_000.0, + fees: 0.001, + slippage: 0.0, + stop: StopConfig::None, + target: TargetConfig::None, + upon_bar_close: true, + } + } +} + +/// Per-instrument configuration for position sizing and risk management. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstrumentConfig { + /// Minimum tradeable quantity (1.0 for NSE EQ, 50.0 for NIFTY F&O, 0.01 for forex). + pub lot_size: Option, + /// Per-instrument capital cap. + pub alloted_capital: Option, + /// Per-instrument stop override. + pub stop: Option, + /// Per-instrument target override. + pub target: Option, + /// Existing position quantity (future use). + pub existing_qty: Option, + /// Existing position average price (future use). + pub avg_price: Option, +} + +impl InstrumentConfig { + /// Round a raw position size down to the nearest lot_size multiple. + /// Returns raw_size unchanged if lot_size is None or <= 0. + pub fn round_to_lot(&self, raw_size: f64) -> f64 { + match self.lot_size { + Some(lot) if lot > 0.0 => (raw_size / lot).floor() * lot, + _ => raw_size, + } + } +} + +impl Default for InstrumentConfig { + fn default() -> Self { + Self { + lot_size: None, + alloted_capital: None, + stop: None, + target: None, + existing_qty: None, + avg_price: None, + } + } +} + +/// Stop-loss configuration. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum StopConfig { + /// No stop-loss. + None, + /// Fixed percentage stop. + Fixed { percent: f64 }, + /// ATR-based stop. + Atr { multiplier: f64, period: usize }, + /// Trailing stop. + Trailing { percent: f64 }, +} + +/// Take-profit configuration. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum TargetConfig { + /// No take-profit. + None, + /// Fixed percentage target. + Fixed { percent: f64 }, + /// ATR-based target. + Atr { multiplier: f64, period: usize }, + /// Risk-reward ratio target. + RiskReward { ratio: f64 }, +} + +/// Backtest metrics. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BacktestMetrics { + /// Total return percentage. + pub total_return_pct: f64, + /// Sharpe ratio (annualized). + pub sharpe_ratio: f64, + /// Sortino ratio (annualized). + pub sortino_ratio: f64, + /// Calmar ratio. + pub calmar_ratio: f64, + /// Omega ratio. + pub omega_ratio: f64, + /// Maximum drawdown percentage. + pub max_drawdown_pct: f64, + /// Maximum drawdown duration in bars. + pub max_drawdown_duration: usize, + /// Win rate percentage. + pub win_rate_pct: f64, + /// Profit factor. + pub profit_factor: f64, + /// Expectancy (average expected profit per trade). + pub expectancy: f64, + /// System Quality Number (SQN). + pub sqn: f64, + /// Total number of trades. + pub total_trades: usize, + /// Number of closed trades. + pub total_closed_trades: usize, + /// Number of open trades at end. + pub total_open_trades: usize, + /// PnL of open trades. + pub open_trade_pnl: f64, + /// Number of winning trades. + pub winning_trades: usize, + /// Number of losing trades. + pub losing_trades: usize, + /// Starting portfolio value. + pub start_value: f64, + /// Ending portfolio value. + pub end_value: f64, + /// Total fees paid. + pub total_fees_paid: f64, + /// Best trade return percentage. + pub best_trade_pct: f64, + /// Worst trade return percentage. + pub worst_trade_pct: f64, + /// Average trade return percentage. + pub avg_trade_return_pct: f64, + /// Average winning trade return percentage. + pub avg_win_pct: f64, + /// Average losing trade return percentage. + pub avg_loss_pct: f64, + /// Average winning trade duration in bars. + pub avg_winning_duration: f64, + /// Average losing trade duration in bars. + pub avg_losing_duration: f64, + /// Maximum consecutive wins. + pub max_consecutive_wins: usize, + /// Maximum consecutive losses. + pub max_consecutive_losses: usize, + /// Average holding period in bars. + pub avg_holding_period: f64, + /// Exposure time percentage (time in market). + pub exposure_pct: f64, + /// Payoff ratio (avg win / avg loss). + pub payoff_ratio: f64, + /// Recovery factor (net profit / max drawdown). + pub recovery_factor: f64, +} + +/// Complete backtest result. +#[derive(Debug, Clone)] +pub struct BacktestResult { + /// Computed metrics. + pub metrics: BacktestMetrics, + /// Equity curve (portfolio value over time). + pub equity_curve: Vec, + /// Drawdown curve (drawdown percentage over time). + pub drawdown_curve: Vec, + /// List of executed trades. + pub trades: Vec, + /// Daily returns. + pub returns: Vec, +} + +impl BacktestResult { + /// Create a new backtest result. + pub fn new( + metrics: BacktestMetrics, + equity_curve: Vec, + drawdown_curve: Vec, + trades: Vec, + returns: Vec, + ) -> Self { + Self { metrics, equity_curve, drawdown_curve, trades, returns } + } +} + +/// Position state during backtest. +#[derive(Debug, Clone)] +pub struct Position { + /// Whether position is open. + pub is_open: bool, + /// Entry bar index. + pub entry_idx: usize, + /// Entry price. + pub entry_price: Price, + /// Position size. + pub size: f64, + /// Trading direction. + pub direction: Direction, + /// Current stop price. + pub stop_price: Option, + /// Current target price. + pub target_price: Option, + /// Highest price since entry (for trailing stops). + pub highest_since_entry: Price, + /// Lowest price since entry (for trailing stops). + pub lowest_since_entry: Price, + /// Entry fees included in trade PnL. + pub entry_fees: f64, +} + +impl Position { + /// Create a new closed position state. + pub fn new() -> Self { + Self { + is_open: false, + entry_idx: 0, + entry_price: 0.0, + size: 0.0, + direction: Direction::Long, + stop_price: None, + target_price: None, + highest_since_entry: 0.0, + lowest_since_entry: f64::MAX, + entry_fees: 0.0, + } + } + + /// Open a new position. + pub fn open( + &mut self, + idx: usize, + price: Price, + size: f64, + direction: Direction, + stop_price: Option, + target_price: Option, + entry_fees: f64, + ) { + self.is_open = true; + self.entry_idx = idx; + self.entry_price = price; + self.size = size; + self.direction = direction; + self.stop_price = stop_price; + self.target_price = target_price; + self.highest_since_entry = price; + self.lowest_since_entry = price; + self.entry_fees = entry_fees; + } + + /// Close the position. + pub fn close(&mut self) { + self.is_open = false; + } + + /// Update highest/lowest prices for trailing stops. + pub fn update_extremes(&mut self, high: Price, low: Price) { + if high > self.highest_since_entry { + self.highest_since_entry = high; + } + if low < self.lowest_since_entry { + self.lowest_since_entry = low; + } + } + + /// Calculate unrealized P&L at given price. + pub fn unrealized_pnl(&self, current_price: Price) -> f64 { + if !self.is_open { + return 0.0; + } + let price_change = current_price - self.entry_price; + price_change * self.size * self.direction.multiplier() + } +} + +impl Default for Position { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_round_to_lot_whole_shares() { + let config = InstrumentConfig { lot_size: Some(1.0), ..Default::default() }; + assert_eq!(config.round_to_lot(242.47), 242.0); + assert_eq!(config.round_to_lot(1.0), 1.0); + assert_eq!(config.round_to_lot(0.5), 0.0); + } + + #[test] + fn test_round_to_lot_nifty_fo() { + let config = InstrumentConfig { lot_size: Some(50.0), ..Default::default() }; + assert_eq!(config.round_to_lot(242.0), 200.0); + assert_eq!(config.round_to_lot(50.0), 50.0); + assert_eq!(config.round_to_lot(49.0), 0.0); + assert_eq!(config.round_to_lot(150.0), 150.0); + } + + #[test] + fn test_round_to_lot_fractional() { + let config = InstrumentConfig { lot_size: Some(0.01), ..Default::default() }; + assert!((config.round_to_lot(1.234) - 1.23).abs() < 1e-10); + } + + #[test] + fn test_round_to_lot_none() { + let config = InstrumentConfig::default(); + assert_eq!(config.round_to_lot(242.47), 242.47); + } + + #[test] + fn test_round_to_lot_zero() { + let config = InstrumentConfig { lot_size: Some(0.0), ..Default::default() }; + assert_eq!(config.round_to_lot(242.47), 242.47); + } + + #[test] + fn test_round_to_lot_negative() { + let config = InstrumentConfig { lot_size: Some(-1.0), ..Default::default() }; + assert_eq!(config.round_to_lot(242.47), 242.47); + } +} diff --git a/src/execution/fees.rs b/src/execution/fees.rs new file mode 100644 index 0000000..3c90de2 --- /dev/null +++ b/src/execution/fees.rs @@ -0,0 +1,157 @@ +//! Fee calculation models. + +use crate::core::types::{Direction, Price}; + +/// Fee model for calculating transaction costs. +#[derive(Debug, Clone)] +pub enum FeeModel { + /// No fees. + None, + /// Fixed percentage of trade value. + Percentage(f64), + /// Fixed fee per trade. + Fixed(f64), + /// Per-share/contract fee. + PerShare(f64), + /// Tiered fee structure based on trade value. + Tiered(Vec<(f64, f64)>), // (threshold, rate) + /// Custom fee function (stored as percentage for simplicity). + Custom { base: f64, per_share: f64 }, +} + +impl Default for FeeModel { + fn default() -> Self { + FeeModel::Percentage(0.001) // 0.1% default + } +} + +impl FeeModel { + /// Create a new percentage fee model. + pub fn percentage(rate: f64) -> Self { + FeeModel::Percentage(rate) + } + + /// Create a new fixed fee model. + pub fn fixed(amount: f64) -> Self { + FeeModel::Fixed(amount) + } + + /// Create a new per-share fee model. + pub fn per_share(rate: f64) -> Self { + FeeModel::PerShare(rate) + } + + /// Calculate fee for a trade. + /// + /// # Arguments + /// * `price` - Trade price + /// * `size` - Position size (shares/contracts) + /// * `direction` - Trade direction (for asymmetric fees if needed) + /// + /// # Returns + /// Fee amount + pub fn calculate(&self, price: Price, size: f64, _direction: Direction) -> f64 { + let trade_value = price * size.abs(); + + match self { + FeeModel::None => 0.0, + FeeModel::Percentage(rate) => trade_value * rate, + FeeModel::Fixed(amount) => *amount, + FeeModel::PerShare(rate) => size.abs() * rate, + FeeModel::Tiered(tiers) => { + // Find applicable tier + let mut applicable_rate = 0.0; + for (threshold, rate) in tiers { + if trade_value >= *threshold { + applicable_rate = *rate; + } else { + break; + } + } + trade_value * applicable_rate + } + FeeModel::Custom { base, per_share } => base + size.abs() * per_share, + } + } + + /// Calculate round-trip fees (entry + exit). + pub fn round_trip( + &self, + entry_price: Price, + exit_price: Price, + size: f64, + direction: Direction, + ) -> f64 { + self.calculate(entry_price, size, direction) + self.calculate(exit_price, size, direction) + } +} + +/// Broker-specific fee configurations. +pub struct BrokerFees; + +impl BrokerFees { + /// Interactive Brokers tiered pricing (approximate). + pub fn interactive_brokers() -> FeeModel { + FeeModel::Custom { base: 1.0, per_share: 0.005 } + } + + /// Zero commission broker (like Robinhood). + pub fn zero_commission() -> FeeModel { + FeeModel::None + } + + /// Indian broker (Zerodha-like). + pub fn india_equity() -> FeeModel { + // 0.03% or Rs 20 per trade, whichever is lower + // Simplified as 0.03% + FeeModel::Percentage(0.0003) + } + + /// Crypto exchange (typical). + pub fn crypto_exchange() -> FeeModel { + FeeModel::Percentage(0.001) // 0.1% maker/taker + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_percentage_fee() { + let fee = FeeModel::percentage(0.001); + let result = fee.calculate(100.0, 100.0, Direction::Long); + assert!((result - 10.0).abs() < 1e-10); // 100 * 100 * 0.001 = 10 + } + + #[test] + fn test_fixed_fee() { + let fee = FeeModel::fixed(5.0); + let result = fee.calculate(100.0, 100.0, Direction::Long); + assert!((result - 5.0).abs() < 1e-10); + } + + #[test] + fn test_per_share_fee() { + let fee = FeeModel::per_share(0.01); + let result = fee.calculate(100.0, 100.0, Direction::Long); + assert!((result - 1.0).abs() < 1e-10); // 100 * 0.01 = 1 + } + + #[test] + fn test_round_trip() { + let fee = FeeModel::percentage(0.001); + let result = fee.round_trip(100.0, 110.0, 100.0, Direction::Long); + // Entry: 100 * 100 * 0.001 = 10 + // Exit: 110 * 100 * 0.001 = 11 + // Total: 21 + assert!((result - 21.0).abs() < 1e-10); + } + + #[test] + fn test_no_fee() { + let fee = FeeModel::None; + let result = fee.calculate(100.0, 100.0, Direction::Long); + assert!((result - 0.0).abs() < 1e-10); + } +} diff --git a/src/execution/fill.rs b/src/execution/fill.rs new file mode 100644 index 0000000..c5ef7d3 --- /dev/null +++ b/src/execution/fill.rs @@ -0,0 +1,361 @@ +//! Order fill simulation models. + +use crate::core::types::{Direction, OhlcvBar, Price}; + +/// Fill price model determining at what price orders are executed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FillPrice { + /// Execute at close price (end of bar). + Close, + /// Execute at open price (start of next bar). + Open, + /// Execute at OHLC average. + Average, + /// Execute at typical price (H+L+C)/3. + Typical, + /// Execute at VWAP (if available, otherwise typical). + Vwap, + /// Execute at worst price (high for buys, low for sells). + Worst, + /// Execute at best price (low for buys, high for sells). + Best, +} + +impl Default for FillPrice { + fn default() -> Self { + FillPrice::Close + } +} + +impl FillPrice { + /// Get execution price from OHLCV bar. + /// + /// # Arguments + /// * `bar` - OHLCV bar data + /// * `direction` - Trade direction + /// * `is_entry` - Whether this is an entry or exit + /// + /// # Returns + /// Execution price + pub fn get_price(&self, bar: &OhlcvBar, direction: Direction, is_entry: bool) -> Price { + match self { + FillPrice::Close => bar.close, + FillPrice::Open => bar.open, + FillPrice::Average => (bar.open + bar.high + bar.low + bar.close) / 4.0, + FillPrice::Typical => (bar.high + bar.low + bar.close) / 3.0, + FillPrice::Vwap => (bar.high + bar.low + bar.close) / 3.0, // Simplified + FillPrice::Worst => { + // Worst price for the trade + match (direction, is_entry) { + (Direction::Long, true) => bar.high, // Buy high + (Direction::Long, false) => bar.low, // Sell low + (Direction::Short, true) => bar.low, // Short at low (bad) + (Direction::Short, false) => bar.high, // Cover at high (bad) + } + } + FillPrice::Best => { + // Best price for the trade + match (direction, is_entry) { + (Direction::Long, true) => bar.low, // Buy low + (Direction::Long, false) => bar.high, // Sell high + (Direction::Short, true) => bar.high, // Short at high (good) + (Direction::Short, false) => bar.low, // Cover at low (good) + } + } + } + } + + /// Get execution price from separate arrays. + /// + /// # Arguments + /// * `open` - Open price + /// * `high` - High price + /// * `low` - Low price + /// * `close` - Close price + /// * `direction` - Trade direction + /// * `is_entry` - Whether this is an entry or exit + /// + /// # Returns + /// Execution price + pub fn get_price_from_arrays( + &self, + open: Price, + high: Price, + low: Price, + close: Price, + direction: Direction, + is_entry: bool, + ) -> Price { + match self { + FillPrice::Close => close, + FillPrice::Open => open, + FillPrice::Average => (open + high + low + close) / 4.0, + FillPrice::Typical => (high + low + close) / 3.0, + FillPrice::Vwap => (high + low + close) / 3.0, + FillPrice::Worst => match (direction, is_entry) { + (Direction::Long, true) => high, + (Direction::Long, false) => low, + (Direction::Short, true) => low, + (Direction::Short, false) => high, + }, + FillPrice::Best => match (direction, is_entry) { + (Direction::Long, true) => low, + (Direction::Long, false) => high, + (Direction::Short, true) => high, + (Direction::Short, false) => low, + }, + } + } +} + +/// Fill model combining price model with execution rules. +#[derive(Debug, Clone)] +pub struct FillModel { + /// Price model for fills. + pub fill_price: FillPrice, + /// Whether to delay execution to next bar. + pub delay_to_next_bar: bool, + /// Partial fill ratio (1.0 = full fill). + pub fill_ratio: f64, +} + +impl Default for FillModel { + fn default() -> Self { + Self { fill_price: FillPrice::Close, delay_to_next_bar: false, fill_ratio: 1.0 } + } +} + +impl FillModel { + /// Create a fill model that executes at close. + pub fn at_close() -> Self { + Self { fill_price: FillPrice::Close, delay_to_next_bar: false, fill_ratio: 1.0 } + } + + /// Create a fill model that executes at next bar's open. + pub fn at_next_open() -> Self { + Self { fill_price: FillPrice::Open, delay_to_next_bar: true, fill_ratio: 1.0 } + } + + /// Set partial fill ratio. + pub fn with_fill_ratio(mut self, ratio: f64) -> Self { + self.fill_ratio = ratio.clamp(0.0, 1.0); + self + } + + /// Check if a limit order would be filled. + /// + /// # Arguments + /// * `limit_price` - Limit price + /// * `bar` - OHLCV bar + /// * `direction` - Trade direction + /// * `is_entry` - Whether this is an entry or exit + /// + /// # Returns + /// True if order would be filled + pub fn would_fill_limit( + &self, + limit_price: Price, + bar: &OhlcvBar, + direction: Direction, + is_entry: bool, + ) -> bool { + match (direction, is_entry) { + // Long entry: buy at or below limit + (Direction::Long, true) => bar.low <= limit_price, + // Long exit: sell at or above limit + (Direction::Long, false) => bar.high >= limit_price, + // Short entry: sell at or above limit + (Direction::Short, true) => bar.high >= limit_price, + // Short exit: buy at or below limit + (Direction::Short, false) => bar.low <= limit_price, + } + } + + /// Get fill price for a limit order. + /// + /// Returns limit price if filled, None if not filled. + /// + /// # Arguments + /// * `limit_price` - Limit price + /// * `bar` - OHLCV bar + /// * `direction` - Trade direction + /// * `is_entry` - Whether this is an entry or exit + /// + /// # Returns + /// Fill price or None + pub fn get_limit_fill_price( + &self, + limit_price: Price, + bar: &OhlcvBar, + direction: Direction, + is_entry: bool, + ) -> Option { + if self.would_fill_limit(limit_price, bar, direction, is_entry) { + // For limit orders, fill at limit price (or better if gap) + Some(limit_price) + } else { + None + } + } + + /// Check if a stop order would be triggered. + /// + /// # Arguments + /// * `stop_price` - Stop price + /// * `bar` - OHLCV bar + /// * `direction` - Trade direction + /// * `is_entry` - Whether this is an entry or exit + /// + /// # Returns + /// True if stop would be triggered + pub fn would_trigger_stop( + &self, + stop_price: Price, + bar: &OhlcvBar, + direction: Direction, + is_entry: bool, + ) -> bool { + match (direction, is_entry) { + // Long entry stop: buy when price rises to stop + (Direction::Long, true) => bar.high >= stop_price, + // Long exit stop: sell when price falls to stop + (Direction::Long, false) => bar.low <= stop_price, + // Short entry stop: sell when price falls to stop + (Direction::Short, true) => bar.low <= stop_price, + // Short exit stop: buy when price rises to stop + (Direction::Short, false) => bar.high >= stop_price, + } + } + + /// Get fill price for a stop order. + /// + /// Returns fill price if triggered, None if not. + /// Uses worst-case scenario (stop price or worse). + /// + /// # Arguments + /// * `stop_price` - Stop price + /// * `bar` - OHLCV bar + /// * `direction` - Trade direction + /// * `is_entry` - Whether this is an entry or exit + /// + /// # Returns + /// Fill price or None + pub fn get_stop_fill_price( + &self, + stop_price: Price, + bar: &OhlcvBar, + direction: Direction, + is_entry: bool, + ) -> Option { + if !self.would_trigger_stop(stop_price, bar, direction, is_entry) { + return None; + } + + // Check for gap through stop + match (direction, is_entry) { + (Direction::Long, true) => { + // Buy stop: fill at stop or worse (gap up through stop) + if bar.open >= stop_price { + Some(bar.open) // Gap up, fill at open + } else { + Some(stop_price) + } + } + (Direction::Long, false) => { + // Sell stop: fill at stop or worse (gap down through stop) + if bar.open <= stop_price { + Some(bar.open) // Gap down, fill at open + } else { + Some(stop_price) + } + } + (Direction::Short, true) => { + // Short stop: fill at stop or worse (gap down through stop) + if bar.open <= stop_price { + Some(bar.open) + } else { + Some(stop_price) + } + } + (Direction::Short, false) => { + // Cover stop: fill at stop or worse (gap up through stop) + if bar.open >= stop_price { + Some(bar.open) + } else { + Some(stop_price) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_bar() -> OhlcvBar { + OhlcvBar { timestamp: 0, open: 100.0, high: 105.0, low: 95.0, close: 102.0, volume: 1000.0 } + } + + #[test] + fn test_fill_price_close() { + let bar = test_bar(); + let fp = FillPrice::Close; + assert!((fp.get_price(&bar, Direction::Long, true) - 102.0).abs() < 1e-10); + } + + #[test] + fn test_fill_price_worst() { + let bar = test_bar(); + let fp = FillPrice::Worst; + + // Long entry: high (105) + assert!((fp.get_price(&bar, Direction::Long, true) - 105.0).abs() < 1e-10); + + // Long exit: low (95) + assert!((fp.get_price(&bar, Direction::Long, false) - 95.0).abs() < 1e-10); + } + + #[test] + fn test_limit_fill() { + let fill = FillModel::default(); + let bar = test_bar(); + + // Limit buy at 96 should fill (low is 95) + assert!(fill.would_fill_limit(96.0, &bar, Direction::Long, true)); + + // Limit buy at 94 should not fill (low is 95) + assert!(!fill.would_fill_limit(94.0, &bar, Direction::Long, true)); + } + + #[test] + fn test_stop_fill() { + let fill = FillModel::default(); + let bar = test_bar(); + + // Stop sell at 96 should trigger (low is 95) + assert!(fill.would_trigger_stop(96.0, &bar, Direction::Long, false)); + + // Stop sell at 94 should not trigger (low is 95) + assert!(!fill.would_trigger_stop(94.0, &bar, Direction::Long, false)); + } + + #[test] + fn test_gap_through_stop() { + let fill = FillModel::default(); + + // Gap down through stop + let gap_bar = OhlcvBar { + timestamp: 0, + open: 90.0, // Gap down from stop at 95 + high: 92.0, + low: 88.0, + close: 91.0, + volume: 1000.0, + }; + + let fill_price = fill.get_stop_fill_price(95.0, &gap_bar, Direction::Long, false); + // Should fill at open (90) not stop (95) + assert_eq!(fill_price, Some(90.0)); + } +} diff --git a/src/execution/mod.rs b/src/execution/mod.rs new file mode 100644 index 0000000..261e273 --- /dev/null +++ b/src/execution/mod.rs @@ -0,0 +1,9 @@ +//! Order execution simulation for RaptorBT. + +pub mod fees; +pub mod fill; +pub mod slippage; + +pub use fees::FeeModel; +pub use fill::{FillModel, FillPrice}; +pub use slippage::SlippageModel; diff --git a/src/execution/slippage.rs b/src/execution/slippage.rs new file mode 100644 index 0000000..44cbb09 --- /dev/null +++ b/src/execution/slippage.rs @@ -0,0 +1,204 @@ +//! Slippage models for realistic trade execution. + +use crate::core::types::{Direction, Price}; + +/// Slippage model for simulating execution price deviation. +#[derive(Debug, Clone)] +pub enum SlippageModel { + /// No slippage. + None, + /// Fixed percentage slippage. + Percentage(f64), + /// Fixed point slippage. + Fixed(f64), + /// Volume-based slippage (higher volume = lower slippage). + VolumeBased { base: f64, volume_factor: f64 }, + /// Spread-based slippage (uses bid-ask spread). + SpreadBased { half_spread: f64 }, +} + +impl Default for SlippageModel { + fn default() -> Self { + SlippageModel::None + } +} + +impl SlippageModel { + /// Create a new percentage slippage model. + pub fn percentage(rate: f64) -> Self { + SlippageModel::Percentage(rate) + } + + /// Create a new fixed slippage model. + pub fn fixed(points: f64) -> Self { + SlippageModel::Fixed(points) + } + + /// Create a volume-based slippage model. + pub fn volume_based(base: f64, volume_factor: f64) -> Self { + SlippageModel::VolumeBased { base, volume_factor } + } + + /// Calculate slippage for a trade. + /// + /// For long entries and short exits: slippage is ADDED to price (pay more/receive less) + /// For short entries and long exits: slippage is SUBTRACTED from price + /// + /// # Arguments + /// * `price` - Base execution price + /// * `direction` - Trade direction + /// * `is_entry` - Whether this is an entry or exit + /// * `volume` - Optional volume for volume-based models + /// + /// # Returns + /// Slippage amount (positive = unfavorable) + pub fn calculate( + &self, + price: Price, + direction: Direction, + is_entry: bool, + volume: Option, + ) -> f64 { + let base_slippage = match self { + SlippageModel::None => 0.0, + SlippageModel::Percentage(rate) => price * rate, + SlippageModel::Fixed(points) => *points, + SlippageModel::VolumeBased { base, volume_factor } => { + if let Some(vol) = volume { + if vol > 0.0 { + base * (1.0 / (1.0 + vol * volume_factor)) + } else { + *base + } + } else { + *base + } + } + SlippageModel::SpreadBased { half_spread } => *half_spread, + }; + + // Determine sign based on trade type + // Long entry: pay higher price (positive slippage) + // Long exit: receive lower price (negative slippage) + // Short entry: receive higher price (negative slippage means worse) + // Short exit: pay higher price + match (direction, is_entry) { + (Direction::Long, true) => base_slippage, // Pay more + (Direction::Long, false) => -base_slippage, // Receive less + (Direction::Short, true) => -base_slippage, // Receive less + (Direction::Short, false) => base_slippage, // Pay more + } + } + + /// Apply slippage to get execution price. + /// + /// # Arguments + /// * `price` - Base price + /// * `direction` - Trade direction + /// * `is_entry` - Whether this is an entry or exit + /// * `volume` - Optional volume for volume-based models + /// + /// # Returns + /// Execution price after slippage + pub fn apply( + &self, + price: Price, + direction: Direction, + is_entry: bool, + volume: Option, + ) -> Price { + price + self.calculate(price, direction, is_entry, volume) + } +} + +/// Market impact model for large orders. +#[derive(Debug, Clone)] +pub struct MarketImpact { + /// Temporary impact coefficient. + pub temporary_impact: f64, + /// Permanent impact coefficient. + pub permanent_impact: f64, + /// Average daily volume for normalization. + pub avg_daily_volume: f64, +} + +impl MarketImpact { + /// Create a new market impact model. + pub fn new(temporary: f64, permanent: f64, adv: f64) -> Self { + Self { temporary_impact: temporary, permanent_impact: permanent, avg_daily_volume: adv } + } + + /// Calculate market impact for an order. + /// + /// Uses simplified square-root model: impact = sigma * sqrt(Q / ADV) + /// + /// # Arguments + /// * `order_size` - Number of shares/contracts + /// * `price` - Current price + /// * `volatility` - Price volatility (sigma) + /// + /// # Returns + /// Total market impact in price terms + pub fn calculate(&self, order_size: f64, price: Price, volatility: f64) -> f64 { + if self.avg_daily_volume <= 0.0 { + return 0.0; + } + + let participation_rate = order_size / self.avg_daily_volume; + let sqrt_participation = participation_rate.sqrt(); + + let temporary = self.temporary_impact * volatility * price * sqrt_participation; + let permanent = self.permanent_impact * volatility * price * participation_rate; + + temporary + permanent + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_percentage_slippage() { + let slip = SlippageModel::percentage(0.001); + + // Long entry: pay more + let entry_slip = slip.calculate(100.0, Direction::Long, true, None); + assert!((entry_slip - 0.1).abs() < 1e-10); + + // Long exit: receive less + let exit_slip = slip.calculate(100.0, Direction::Long, false, None); + assert!((exit_slip - (-0.1)).abs() < 1e-10); + } + + #[test] + fn test_apply_slippage() { + let slip = SlippageModel::percentage(0.001); + + // Long entry at 100 should pay 100.1 + let entry_price = slip.apply(100.0, Direction::Long, true, None); + assert!((entry_price - 100.1).abs() < 1e-10); + + // Long exit at 100 should receive 99.9 + let exit_price = slip.apply(100.0, Direction::Long, false, None); + assert!((exit_price - 99.9).abs() < 1e-10); + } + + #[test] + fn test_no_slippage() { + let slip = SlippageModel::None; + let result = slip.apply(100.0, Direction::Long, true, None); + assert!((result - 100.0).abs() < 1e-10); + } + + #[test] + fn test_volume_based_slippage() { + let slip = SlippageModel::volume_based(0.1, 0.0001); + + // High volume should have lower slippage + let high_vol = slip.calculate(100.0, Direction::Long, true, Some(100000.0)); + let low_vol = slip.calculate(100.0, Direction::Long, true, Some(1000.0)); + + assert!(high_vol < low_vol); + } +} diff --git a/src/indicators/ferro_bridge.rs b/src/indicators/ferro_bridge.rs new file mode 100644 index 0000000..dc6e2cd --- /dev/null +++ b/src/indicators/ferro_bridge.rs @@ -0,0 +1,847 @@ +use crate::core::error::RaptorError; +use crate::core::Result; + +pub struct AroonResult { + pub up: Vec, + pub down: Vec, +} + +pub struct AdxAllResult { + pub adx: Vec, + pub plus_di: Vec, + pub minus_di: Vec, +} + +fn ema_nan_safe(data: &[f64], period: usize) -> Vec { + let n = data.len(); + let mut result = vec![f64::NAN; n]; + if period == 0 || n < period { + return result; + } + let k = 2.0 / (period as f64 + 1.0); + let mut seed_sum = 0.0; + let mut seed_count = 0usize; + let mut first_valid = None; + for i in 0..n { + if !data[i].is_nan() { + seed_sum += data[i]; + seed_count += 1; + if seed_count == period { + first_valid = Some(i); + result[i] = seed_sum / period as f64; + break; + } + } + } + if let Some(start) = first_valid { + for i in (start + 1)..n { + if !data[i].is_nan() { + result[i] = data[i] * k + result[i - 1] * (1.0 - k); + } + } + } + result +} + +pub fn cci(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("CCI period must be > 0")); + } + Ok(ferro_ta_core::momentum::cci(high, low, close, period)) +} + +pub fn willr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("Williams %R period must be > 0")); + } + Ok(ferro_ta_core::momentum::willr(high, low, close, period)) +} + +pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Result> { + if acceleration <= 0.0 || maximum <= 0.0 { + return Err(RaptorError::invalid_parameter( + "SAR acceleration and maximum must be > 0", + )); + } + Ok(ferro_ta_core::overlap::sar(high, low, acceleration, maximum)) +} + +pub fn plus_di(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("+DI period must be > 0")); + } + Ok(ferro_ta_core::momentum::plus_di(high, low, close, period)) +} + +pub fn minus_di(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("-DI period must be > 0")); + } + Ok(ferro_ta_core::momentum::minus_di(high, low, close, period)) +} + +pub fn adx_all(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result { + if period == 0 { + return Err(RaptorError::invalid_parameter("ADX period must be > 0")); + } + let (_pdm_s, _mdm_s, plus_di, minus_di, _dx, adx) = + ferro_ta_core::momentum::adx_all(high, low, close, period); + Ok(AdxAllResult { adx, plus_di, minus_di }) +} + +pub fn adxr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("ADXR period must be > 0")); + } + Ok(ferro_ta_core::momentum::adxr(high, low, close, period)) +} + +pub fn roc(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("ROC period must be > 0")); + } + Ok(ferro_ta_core::momentum::roc(close, period)) +} + +pub fn mfi( + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + period: usize, +) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("MFI period must be > 0")); + } + Ok(ferro_ta_core::volume::mfi(high, low, close, volume, period)) +} + +pub fn wma(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("WMA period must be > 0")); + } + Ok(ferro_ta_core::overlap::wma(close, period)) +} + +pub fn dema(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("DEMA period must be > 0")); + } + let n = close.len(); + let mut result = vec![f64::NAN; n]; + let ema1 = ferro_ta_core::overlap::ema(close, period); + let ema2 = ema_nan_safe(&ema1, period); + for i in 0..n { + if !ema1[i].is_nan() && !ema2[i].is_nan() { + result[i] = 2.0 * ema1[i] - ema2[i]; + } + } + Ok(result) +} + +pub fn tema(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("TEMA period must be > 0")); + } + let n = close.len(); + let mut result = vec![f64::NAN; n]; + let ema1 = ferro_ta_core::overlap::ema(close, period); + let ema2 = ema_nan_safe(&ema1, period); + let ema3 = ema_nan_safe(&ema2, period); + for i in 0..n { + if !ema1[i].is_nan() && !ema2[i].is_nan() && !ema3[i].is_nan() { + result[i] = 3.0 * ema1[i] - 3.0 * ema2[i] + ema3[i]; + } + } + Ok(result) +} + +pub fn kama(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("KAMA period must be > 0")); + } + Ok(ferro_ta_core::overlap::kama(close, period)) +} + +pub fn stochrsi( + close: &[f64], + timeperiod: usize, + fastk_period: usize, + fastd_period: usize, +) -> Result<(Vec, Vec)> { + if timeperiod == 0 { + return Err(RaptorError::invalid_parameter( + "StochRSI timeperiod must be > 0", + )); + } + Ok(ferro_ta_core::momentum::stochrsi( + close, + timeperiod, + fastk_period, + fastd_period, + )) +} + +pub fn aroon(high: &[f64], low: &[f64], period: usize) -> Result { + if period == 0 { + return Err(RaptorError::invalid_parameter("Aroon period must be > 0")); + } + let (down, up) = ferro_ta_core::momentum::aroon(high, low, period); + Ok(AroonResult { up, down }) +} + +pub fn trix(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("TRIX period must be > 0")); + } + let n = close.len(); + let mut result = vec![f64::NAN; n]; + let ema1 = ferro_ta_core::overlap::ema(close, period); + let ema2 = ema_nan_safe(&ema1, period); + let ema3 = ema_nan_safe(&ema2, period); + for i in 1..n { + let prev = ema3[i - 1]; + if !ema3[i].is_nan() && !prev.is_nan() && prev != 0.0 { + result[i] = (ema3[i] - prev) / prev * 100.0; + } + } + Ok(result) +} + +pub fn natr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("NATR period must be > 0")); + } + Ok(ferro_ta_core::volatility::natr(high, low, close, period)) +} + +pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Result> { + Ok(ferro_ta_core::volatility::trange(high, low, close)) +} + +pub fn stddev(real: &[f64], period: usize, nbdev: f64) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("StdDev period must be > 0")); + } + Ok(ferro_ta_core::statistic::stddev(real, period, nbdev)) +} + +pub fn var(real: &[f64], period: usize, nbdev: f64) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("VAR period must be > 0")); + } + Ok(ferro_ta_core::statistic::var(real, period, nbdev)) +} + +pub fn linearreg(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter( + "LinearReg period must be > 0", + )); + } + Ok(ferro_ta_core::statistic::linearreg(close, period)) +} + +pub fn linearreg_slope(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter( + "LinearReg Slope period must be > 0", + )); + } + Ok(ferro_ta_core::statistic::linearreg_slope(close, period)) +} + +pub fn beta(real0: &[f64], real1: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("Beta period must be > 0")); + } + Ok(ferro_ta_core::statistic::beta(real0, real1, period)) +} + +pub fn correl(real0: &[f64], real1: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("Correl period must be > 0")); + } + Ok(ferro_ta_core::statistic::correl(real0, real1, period)) +} + +pub fn ad(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result> { + Ok(ferro_ta_core::volume::ad(high, low, close, volume)) +} + +pub fn adosc( + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + fastperiod: usize, + slowperiod: usize, +) -> Result> { + if fastperiod == 0 || slowperiod == 0 { + return Err(RaptorError::invalid_parameter( + "ADOSC fast/slow period must be > 0", + )); + } + Ok(ferro_ta_core::volume::adosc( + high, low, close, volume, fastperiod, slowperiod, + )) +} + +pub fn obv(close: &[f64], volume: &[f64]) -> Result> { + Ok(ferro_ta_core::volume::obv(close, volume)) +} + +pub fn mom(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("Momentum period must be > 0")); + } + Ok(ferro_ta_core::momentum::mom(close, period)) +} + +pub struct PpoResult { + pub ppo_line: Vec, + pub signal_line: Vec, + pub histogram: Vec, +} + +pub fn ppo( + close: &[f64], + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> Result { + if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 { + return Err(RaptorError::invalid_parameter( + "PPO fast/slow/signal period must be > 0", + )); + } + let n = close.len(); + let fast_ema = ferro_ta_core::overlap::ema(close, fastperiod); + let slow_ema = ferro_ta_core::overlap::ema(close, slowperiod); + + let mut ppo_line = vec![f64::NAN; n]; + for i in 0..n { + if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() && slow_ema[i] != 0.0 { + ppo_line[i] = (fast_ema[i] - slow_ema[i]) / slow_ema[i] * 100.0; + } + } + + let signal_line = ema_nan_safe(&ppo_line, signalperiod); + let mut histogram = vec![f64::NAN; n]; + for i in 0..n { + if !ppo_line[i].is_nan() && !signal_line[i].is_nan() { + histogram[i] = ppo_line[i] - signal_line[i]; + } + } + + Ok(PpoResult { ppo_line, signal_line, histogram }) +} + +pub fn cmo(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("CMO period must be > 0")); + } + Ok(ferro_ta_core::momentum::cmo(close, period)) +} + +pub fn aroonosc(high: &[f64], low: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter( + "Aroon Osc period must be > 0", + )); + } + Ok(ferro_ta_core::momentum::aroonosc(high, low, period)) +} + +pub fn bop( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], +) -> Result> { + Ok(ferro_ta_core::momentum::bop(open, high, low, close)) +} + +pub fn ultosc( + high: &[f64], + low: &[f64], + close: &[f64], + period1: usize, + period2: usize, + period3: usize, +) -> Result> { + if period1 == 0 || period2 == 0 || period3 == 0 { + return Err(RaptorError::invalid_parameter( + "Ultimate Osc periods must be > 0", + )); + } + Ok(ferro_ta_core::momentum::ultosc(high, low, close, period1, period2, period3)) +} + +pub fn typprice(high: &[f64], low: &[f64], close: &[f64]) -> Result> { + Ok(ferro_ta_core::price_transform::typprice(high, low, close)) +} + +pub fn medprice(high: &[f64], low: &[f64]) -> Result> { + Ok(ferro_ta_core::price_transform::medprice(high, low)) +} + +pub fn avgprice( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], +) -> Result> { + Ok(ferro_ta_core::price_transform::avgprice(open, high, low, close)) +} + +pub fn wclprice(high: &[f64], low: &[f64], close: &[f64]) -> Result> { + Ok(ferro_ta_core::price_transform::wclprice(high, low, close)) +} + +pub fn midpoint(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("Midpoint period must be > 0")); + } + Ok(ferro_ta_core::overlap::midpoint(close, period)) +} + +pub fn midprice(high: &[f64], low: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("Midprice period must be > 0")); + } + Ok(ferro_ta_core::overlap::midprice(high, low, period)) +} + +pub fn t3(close: &[f64], period: usize, vfactor: f64) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("T3 period must be > 0")); + } + Ok(ferro_ta_core::overlap::t3(close, period, vfactor)) +} + +pub fn trima(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("TRIMA period must be > 0")); + } + Ok(ferro_ta_core::overlap::trima(close, period)) +} + +pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Result> { + if fastperiod == 0 || slowperiod == 0 { + return Err(RaptorError::invalid_parameter( + "APO fast/slow period must be > 0", + )); + } + Ok(ferro_ta_core::momentum::apo(close, fastperiod, slowperiod)) +} + +pub fn tsf(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("TSF period must be > 0")); + } + Ok(ferro_ta_core::statistic::tsf(close, period)) +} + +pub fn linearreg_angle(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter( + "LinearReg Angle period must be > 0", + )); + } + Ok(ferro_ta_core::statistic::linearreg_angle(close, period)) +} + +pub fn linearreg_intercept(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter( + "LinearReg Intercept period must be > 0", + )); + } + Ok(ferro_ta_core::statistic::linearreg_intercept(close, period)) +} + +// ============================================================================ +// Extended indicators (from ferro_ta_core::extended) +// ============================================================================ + +/// Volume-Weighted Moving Average. +/// +/// # Returns +/// Vector of VWMA values (NaN for warmup period). +pub fn vwma(close: &[f64], volume: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("VWMA period must be > 0")); + } + Ok(ferro_ta_core::extended::vwma(close, volume, period)) +} + +/// Donchian Channels result. +pub struct DonchianResult { + pub upper: Vec, + pub middle: Vec, + pub lower: Vec, +} + +/// Donchian Channels — rolling highest high / lowest low. +/// +/// # Returns +/// `(upper, middle, lower)` arrays. +pub fn donchian(high: &[f64], low: &[f64], period: usize) -> Result { + if period == 0 { + return Err(RaptorError::invalid_parameter("Donchian period must be > 0")); + } + let (upper, middle, lower) = ferro_ta_core::extended::donchian(high, low, period); + Ok(DonchianResult { upper, middle, lower }) +} + +/// Choppiness Index — measures choppy vs trending market (0 = trend, 100 = chop). +/// +/// # Returns +/// Vector of CI values (NaN for warmup period). +pub fn choppiness_index( + high: &[f64], + low: &[f64], + close: &[f64], + period: usize, +) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter( + "Choppiness period must be > 0", + )); + } + Ok(ferro_ta_core::extended::choppiness_index(high, low, close, period)) +} + +/// Hull Moving Average. +/// +/// `HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`. +pub fn hull_ma(close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("Hull MA period must be > 0")); + } + Ok(ferro_ta_core::extended::hull_ma(close, period)) +} + +/// Chandelier Exit result — ATR-based trailing stops. +pub struct ChandelierResult { + pub long_exit: Vec, + pub short_exit: Vec, +} + +/// Chandelier Exit — ATR-based trailing stop levels. +/// +/// # Returns +/// `(long_exit, short_exit)` arrays. +pub fn chandelier_exit( + high: &[f64], + low: &[f64], + close: &[f64], + period: usize, + multiplier: f64, +) -> Result { + if period == 0 { + return Err(RaptorError::invalid_parameter( + "Chandelier period must be > 0", + )); + } + if multiplier <= 0.0 { + return Err(RaptorError::invalid_parameter( + "Chandelier multiplier must be > 0", + )); + } + let (long_exit, short_exit) = + ferro_ta_core::extended::chandelier_exit(high, low, close, period, multiplier); + Ok(ChandelierResult { long_exit, short_exit }) +} + +/// Ichimoku Cloud (Ichimoku Kinko Hyo) result. +pub struct IchimokuResult { + pub tenkan: Vec, + pub kijun: Vec, + pub senkou_a: Vec, + pub senkou_b: Vec, + pub chikou: Vec, +} + +/// Ichimoku Cloud — `(tenkan, kijun, senkou_a, senkou_b, chikou)`. +pub fn ichimoku( + high: &[f64], + low: &[f64], + close: &[f64], + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, +) -> Result { + if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 { + return Err(RaptorError::invalid_parameter( + "Ichimoku periods must be > 0", + )); + } + let (tenkan, kijun, senkou_a, senkou_b, chikou) = ferro_ta_core::extended::ichimoku( + high, + low, + close, + tenkan_period, + kijun_period, + senkou_b_period, + displacement, + ); + Ok(IchimokuResult { tenkan, kijun, senkou_a, senkou_b, chikou }) +} + +/// Pivot Points result — supports classic / fibonacci / camarilla. +pub struct PivotPointsResult { + pub pivot: Vec, + pub r1: Vec, + pub s1: Vec, + pub r2: Vec, + pub s2: Vec, +} + +/// Pivot Points — classic, fibonacci, or camarilla methodology. +/// +/// `method` must be one of: `"classic"`, `"fibonacci"`, `"camarilla"`. +/// Index 0 of each array is always NaN (no previous bar). +pub fn pivot_points( + high: &[f64], + low: &[f64], + close: &[f64], + method: &str, +) -> Result { + let m = method.to_lowercase(); + if !matches!(m.as_str(), "classic" | "fibonacci" | "camarilla") { + return Err(RaptorError::invalid_parameter( + "Pivot method must be 'classic', 'fibonacci', or 'camarilla'", + )); + } + let (pivot, r1, s1, r2, s2) = + ferro_ta_core::extended::pivot_points(high, low, close, &m); + Ok(PivotPointsResult { pivot, r1, s1, r2, s2 }) +} + +// ============================================================================ +// Cycle (Hilbert Transform) indicators (from ferro_ta_core::cycle) +// ============================================================================ + +/// Hilbert Transform — Instantaneous Trendline. +pub fn ht_trendline(close: &[f64]) -> Result> { + if close.len() < 32 { + return Err(RaptorError::invalid_parameter( + "HT Trendline requires at least 32 bars", + )); + } + Ok(ferro_ta_core::cycle::ht_trendline(close)) +} + +/// Hilbert Transform — Dominant Cycle Period. +pub fn ht_dcperiod(close: &[f64]) -> Result> { + if close.len() < 32 { + return Err(RaptorError::invalid_parameter( + "HT DCPeriod requires at least 32 bars", + )); + } + Ok(ferro_ta_core::cycle::ht_dcperiod(close)) +} + +/// Hilbert Transform — Dominant Cycle Phase. +pub fn ht_dcphase(close: &[f64]) -> Result> { + if close.len() < 32 { + return Err(RaptorError::invalid_parameter( + "HT DCPhase requires at least 32 bars", + )); + } + Ok(ferro_ta_core::cycle::ht_dcphase(close)) +} + +/// Hilbert Transform — Phasor Components result. +pub struct HtPhasorResult { + pub in_phase: Vec, + pub quadrature: Vec, +} + +/// Hilbert Transform — Phasor Components `(in_phase, quadrature)`. +pub fn ht_phasor(close: &[f64]) -> Result { + if close.len() < 32 { + return Err(RaptorError::invalid_parameter( + "HT Phasor requires at least 32 bars", + )); + } + let (in_phase, quadrature) = ferro_ta_core::cycle::ht_phasor(close); + Ok(HtPhasorResult { in_phase, quadrature }) +} + +/// Hilbert Transform — Sine Wave result. +pub struct HtSineResult { + pub sine: Vec, + pub lead_sine: Vec, +} + +/// Hilbert Transform — Sine Wave `(sine, lead_sine)`. +pub fn ht_sine(close: &[f64]) -> Result { + if close.len() < 32 { + return Err(RaptorError::invalid_parameter( + "HT Sine requires at least 32 bars", + )); + } + let (sine, lead_sine) = ferro_ta_core::cycle::ht_sine(close); + Ok(HtSineResult { sine, lead_sine }) +} + +/// Hilbert Transform — Trend vs Cycle Mode. +/// +/// Returns `Vec`: `1` = trend mode, `0` = cycle mode. +pub fn ht_trendmode(close: &[f64]) -> Result> { + if close.len() < 32 { + return Err(RaptorError::invalid_parameter( + "HT TrendMode requires at least 32 bars", + )); + } + Ok(ferro_ta_core::cycle::ht_trendmode(close)) +} + +// ============================================================================ +// Market regime detection (from ferro_ta_core::regime) +// ============================================================================ + +/// Trend/range regime labels based on ADX threshold. +/// +/// Returns `Vec`: `1` = trend, `0` = range, `-1` = warmup/NaN. +pub fn regime_adx(adx: &[f64], threshold: f64) -> Result> { + Ok(ferro_ta_core::regime::regime_adx(adx, threshold)) +} + +/// Trend/range regime using ADX + ATR-ratio rule. +/// +/// 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, +) -> Result> { + Ok(ferro_ta_core::regime::regime_combined( + adx, + atr, + close, + adx_threshold, + atr_pct_threshold, + )) +} + +/// Detect structural breaks via CUSUM test. +/// +/// Returns `Vec`: `1` at break bars, `0` elsewhere. +pub fn detect_breaks_cusum( + series: &[f64], + window: usize, + threshold: f64, + slack: f64, +) -> Result> { + if window < 2 { + return Err(RaptorError::invalid_parameter( + "CUSUM window must be >= 2", + )); + } + Ok(ferro_ta_core::regime::detect_breaks_cusum( + series, window, threshold, slack, + )) +} + +/// Detect volatility regime breaks using rolling variance ratio. +/// +/// `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, +) -> Result> { + if short_window < 2 { + return Err(RaptorError::invalid_parameter( + "Variance break short_window must be >= 2", + )); + } + if long_window <= short_window { + return Err(RaptorError::invalid_parameter( + "Variance break long_window must be > short_window", + )); + } + Ok(ferro_ta_core::regime::rolling_variance_break( + series, + short_window, + long_window, + threshold, + )) +} + +// ============================================================================ +// Portfolio / cross-series tools (from ferro_ta_core::portfolio) +// ============================================================================ + +/// Rolling beta — `cov(asset, benchmark) / var(benchmark)` over a sliding window. +pub fn rolling_beta(asset: &[f64], benchmark: &[f64], window: usize) -> Result> { + if window < 2 { + return Err(RaptorError::invalid_parameter( + "Rolling beta window must be >= 2", + )); + } + Ok(ferro_ta_core::portfolio::rolling_beta(asset, benchmark, window)) +} + +/// Drawdown series result. +pub struct DrawdownResult { + /// Per-bar drawdown as a non-positive fraction of peak equity. + pub series: Vec, + /// Maximum drawdown over the full input range (non-positive). + pub max_drawdown: f64, +} + +/// Drawdown series — `(per_bar_drawdown, max_drawdown)` from an equity curve. +pub fn drawdown_series(equity: &[f64]) -> Result { + let (series, max_drawdown) = ferro_ta_core::portfolio::drawdown_series(equity); + Ok(DrawdownResult { series, max_drawdown }) +} + +/// Rolling Z-Score over a window. +pub fn zscore_series(x: &[f64], window: usize) -> Result> { + if window < 2 { + return Err(RaptorError::invalid_parameter( + "Z-score window must be >= 2", + )); + } + Ok(ferro_ta_core::portfolio::zscore_series(x, window)) +} + +/// Relative strength — `asset - beta * benchmark` (excess return style). +pub fn relative_strength(asset_returns: &[f64], benchmark_returns: &[f64]) -> Result> { + if asset_returns.len() != benchmark_returns.len() { + return Err(RaptorError::invalid_parameter( + "Relative strength inputs must have the same length", + )); + } + Ok(ferro_ta_core::portfolio::relative_strength(asset_returns, benchmark_returns)) +} + +/// Spread between two series: `a - hedge * b`. +pub fn spread(a: &[f64], b: &[f64], hedge: f64) -> Result> { + if a.len() != b.len() { + return Err(RaptorError::invalid_parameter( + "Spread inputs must have the same length", + )); + } + Ok(ferro_ta_core::portfolio::spread(a, b, hedge)) +} + +/// Ratio between two series element-wise: `a / b`. +pub fn ratio(a: &[f64], b: &[f64]) -> Result> { + if a.len() != b.len() { + return Err(RaptorError::invalid_parameter( + "Ratio inputs must have the same length", + )); + } + Ok(ferro_ta_core::portfolio::ratio(a, b)) +} \ No newline at end of file diff --git a/src/indicators/mod.rs b/src/indicators/mod.rs new file mode 100644 index 0000000..9e9a65d --- /dev/null +++ b/src/indicators/mod.rs @@ -0,0 +1,36 @@ +//! Technical indicators for RaptorBT. +//! +//! All indicators are implemented as pure functions that take slice inputs +//! and return Vec outputs. NaN values are used for the warmup period. + +pub mod ferro_bridge; +pub mod momentum; +pub mod rolling; +pub mod strength; +pub mod tick_features; +pub mod trend; +pub mod volatility; +pub mod volume; + +pub use ferro_bridge::{ + AdxAllResult, AroonResult, ChandelierResult, DonchianResult, DrawdownResult, HtPhasorResult, + HtSineResult, IchimokuResult, PivotPointsResult, PpoResult, ad, adosc, adx_all, aroon, + aroonosc, apo, avgprice, beta, bop, cci, chandelier_exit, choppiness_index, correl, dema, + detect_breaks_cusum, donchian, drawdown_series, ht_dcperiod, ht_dcphase, ht_phasor, + ht_sine, ht_trendline, ht_trendmode, hull_ma, ichimoku, kama, linearreg, linearreg_angle, + linearreg_intercept, linearreg_slope, medprice, mfi, midpoint, midprice, minus_di, mom, + natr, obv, pivot_points, plus_di, ppo, ratio, regime_adx, regime_combined, + relative_strength, roc, rolling_beta, rolling_variance_break, sar, spread, stddev, + stochrsi, t3, tema, trange, trix, trima, tsf, typprice, ultosc, var, vwma, wclprice, willr, + wma, zscore_series, +}; +pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult}; +pub use rolling::{rolling_max, rolling_min}; +pub use strength::adx; +pub use tick_features::{ + buy_sell_imbalance_delta, oi_position_pct, realized_vol_rolling, return_window, spread_pct, + tick_velocity, +}; +pub use trend::{ema, sma, supertrend, SupertrendResult}; +pub use volatility::{atr, bollinger_bands, BollingerBandsResult}; +pub use volume::{obv as obv_native, vwap}; \ No newline at end of file diff --git a/src/indicators/momentum.rs b/src/indicators/momentum.rs new file mode 100644 index 0000000..f6a6753 --- /dev/null +++ b/src/indicators/momentum.rs @@ -0,0 +1,147 @@ +//! Momentum indicators: RSI, MACD, Stochastic. + +use crate::core::error::RaptorError; +use crate::core::Result; + +/// Relative Strength Index (RSI). +/// +/// # Arguments +/// * `data` - Price data (typically close prices) +/// * `period` - Lookback period (default: 14) +/// +/// # Returns +/// Vector of RSI values (0-100 scale, NaN for warmup period) +pub fn rsi(data: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("RSI period must be > 0")); + } + Ok(ferro_ta_core::momentum::rsi(data, period)) +} + +/// MACD result structure. +#[derive(Debug, Clone)] +pub struct MacdResult { + /// MACD line (fast EMA - slow EMA). + pub macd_line: Vec, + /// Signal line (EMA of MACD line). + pub signal_line: Vec, + /// Histogram (MACD line - signal line). + pub histogram: Vec, +} + +/// Moving Average Convergence Divergence (MACD). +/// +/// # Arguments +/// * `data` - Price data (typically close prices) +/// * `fast_period` - Fast EMA period (default: 12) +/// * `slow_period` - Slow EMA period (default: 26) +/// * `signal_period` - Signal line EMA period (default: 9) +/// +/// # Returns +/// MacdResult with MACD line, signal line, and histogram +pub fn macd( + data: &[f64], + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> Result { + if fast_period == 0 || slow_period == 0 || signal_period == 0 { + return Err(RaptorError::invalid_parameter("MACD periods must be > 0")); + } + if fast_period >= slow_period { + return Err(RaptorError::invalid_parameter("MACD fast period must be < slow period")); + } + let (macd_line, signal_line, histogram) = + ferro_ta_core::overlap::macd(data, fast_period, slow_period, signal_period); + Ok(MacdResult { macd_line, signal_line, histogram }) +} + +/// Stochastic oscillator result. +#[derive(Debug, Clone)] +pub struct StochasticResult { + /// %K line (fast stochastic). + pub k: Vec, + /// %D line (slow stochastic, SMA of %K). + pub d: Vec, +} + +/// Stochastic Oscillator. +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `k_period` - %K lookback period (default: 14) +/// * `d_period` - %D smoothing period (default: 3) +/// +/// # Returns +/// StochasticResult with %K and %D lines (0-100 scale) +pub fn stochastic( + high: &[f64], + low: &[f64], + close: &[f64], + k_period: usize, + d_period: usize, +) -> Result { + let n = close.len(); + if n != high.len() || n != low.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + if k_period == 0 || d_period == 0 { + return Err(RaptorError::invalid_parameter("Stochastic periods must be > 0")); + } + let (k, d) = ferro_ta_core::momentum::stoch(high, low, close, k_period, d_period, d_period); + Ok(StochasticResult { k, d }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rsi() { + // Test with simple increasing data + let data = vec![ + 44.0, 44.25, 44.5, 43.75, 44.5, 44.25, 44.0, 44.0, 44.25, 45.0, 45.5, 46.0, 46.5, 47.0, + 47.5, + ]; + let result = rsi(&data, 14).unwrap(); + + // RSI should be valid from index 14 + assert!(result[13].is_nan()); + assert!(!result[14].is_nan()); + assert!(result[14] >= 0.0 && result[14] <= 100.0); + } + + #[test] + fn test_macd() { + let data: Vec = (1..=50).map(|x| x as f64).collect(); + let result = macd(&data, 12, 26, 9).unwrap(); + + // MACD line should be valid from index 25 (slow_period - 1) + assert!(result.macd_line[24].is_nan()); + assert!(!result.macd_line[25].is_nan()); + + // Signal line starts at index slow_period-1 + signal_period-1 = 25+8 = 33 + assert!(result.signal_line[32].is_nan()); + assert!(!result.signal_line[33].is_nan()); + } + + #[test] + fn test_stochastic() { + let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5]; + let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5]; + let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0]; + + let result = stochastic(&high, &low, &close, 5, 3).unwrap(); + + // %K should be valid from index 4 + assert!(result.k[3].is_nan()); + assert!(!result.k[4].is_nan()); + assert!(result.k[4] >= 0.0 && result.k[4] <= 100.0); + + // %D should be valid from index 6 + assert!(result.d[5].is_nan()); + assert!(!result.d[6].is_nan()); + } +} \ No newline at end of file diff --git a/src/indicators/rolling.rs b/src/indicators/rolling.rs new file mode 100644 index 0000000..ee872fd --- /dev/null +++ b/src/indicators/rolling.rs @@ -0,0 +1,106 @@ +//! Rolling min/max indicators for LLV/HHV support. +//! +//! Provides rolling minimum and maximum calculations for Lowest Low Value (LLV) +//! and Highest High Value (HHV) expressions. + +use crate::core::error::RaptorError; + +/// Calculate rolling minimum (Lowest Low Value) over a period. +/// +/// Returns NaN for the first (period - 1) values where insufficient data exists. +/// +/// # Arguments +/// * `data` - Input data slice +/// * `period` - Lookback period +/// +/// # Returns +/// Vec of rolling minimum values +pub fn rolling_min(data: &[f64], period: usize) -> Result, RaptorError> { + if period == 0 { + return Err(RaptorError::invalid_parameter("period must be at least 1")); + } + + let n = data.len(); + let mut result = vec![f64::NAN; n]; + + for i in (period - 1)..n { + let start = i + 1 - period; + let min_val = + data[start..=i] + .iter() + .fold(f64::INFINITY, |a, &b| if b.is_nan() { a } else { a.min(b) }); + result[i] = if min_val == f64::INFINITY { f64::NAN } else { min_val }; + } + + Ok(result) +} + +/// Calculate rolling maximum (Highest High Value) over a period. +/// +/// Returns NaN for the first (period - 1) values where insufficient data exists. +/// +/// # Arguments +/// * `data` - Input data slice +/// * `period` - Lookback period +/// +/// # Returns +/// Vec of rolling maximum values +pub fn rolling_max(data: &[f64], period: usize) -> Result, RaptorError> { + if period == 0 { + return Err(RaptorError::invalid_parameter("period must be at least 1")); + } + + let n = data.len(); + let mut result = vec![f64::NAN; n]; + + for i in (period - 1)..n { + let start = i + 1 - period; + let max_val = + data[start..=i] + .iter() + .fold(f64::NEG_INFINITY, |a, &b| if b.is_nan() { a } else { a.max(b) }); + result[i] = if max_val == f64::NEG_INFINITY { f64::NAN } else { max_val }; + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rolling_min() { + let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0]; + let result = rolling_min(&data, 3).unwrap(); + + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 3.0).abs() < f64::EPSILON); // min(5, 3, 8) + assert!((result[3] - 2.0).abs() < f64::EPSILON); // min(3, 8, 2) + assert!((result[4] - 2.0).abs() < f64::EPSILON); // min(8, 2, 7) + assert!((result[5] - 1.0).abs() < f64::EPSILON); // min(2, 7, 1) + assert!((result[6] - 1.0).abs() < f64::EPSILON); // min(7, 1, 9) + } + + #[test] + fn test_rolling_max() { + let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0]; + let result = rolling_max(&data, 3).unwrap(); + + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 8.0).abs() < f64::EPSILON); // max(5, 3, 8) + assert!((result[3] - 8.0).abs() < f64::EPSILON); // max(3, 8, 2) + assert!((result[4] - 8.0).abs() < f64::EPSILON); // max(8, 2, 7) + assert!((result[5] - 7.0).abs() < f64::EPSILON); // max(2, 7, 1) + assert!((result[6] - 9.0).abs() < f64::EPSILON); // max(7, 1, 9) + } + + #[test] + fn test_invalid_period() { + let data = vec![1.0, 2.0, 3.0]; + assert!(rolling_min(&data, 0).is_err()); + assert!(rolling_max(&data, 0).is_err()); + } +} diff --git a/src/indicators/strength.rs b/src/indicators/strength.rs new file mode 100644 index 0000000..f093971 --- /dev/null +++ b/src/indicators/strength.rs @@ -0,0 +1,104 @@ +//! Strength indicators: ADX. + +use crate::core::error::RaptorError; +use crate::core::Result; + +/// Average Directional Index (ADX). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `period` - Lookback period (default: 14) +/// +/// # Returns +/// Vector of ADX values (0-100 scale, NaN for warmup period) +pub fn adx(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + let n = close.len(); + if n != high.len() || n != low.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + if period == 0 { + return Err(RaptorError::invalid_parameter("ADX period must be > 0")); + } + Ok(ferro_ta_core::momentum::adx(high, low, close, period)) +} + +/// Directional Index result including +DI, -DI, and ADX. +#[derive(Debug, Clone)] +pub struct DirectionalIndexResult { + /// +DI values. + pub plus_di: Vec, + /// -DI values. + pub minus_di: Vec, + /// ADX values. + pub adx: Vec, +} + +/// Full Directional Movement System (DI+, DI-, ADX). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `period` - Lookback period (default: 14) +/// +/// # Returns +/// DirectionalIndexResult with +DI, -DI, and ADX +pub fn directional_movement( + high: &[f64], + low: &[f64], + close: &[f64], + period: usize, +) -> Result { + let n = close.len(); + if n != high.len() || n != low.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + if period == 0 { + return Err(RaptorError::invalid_parameter("Period must be > 0")); + } + let (_pdm_s, _mdm_s, plus_di, minus_di, _dx, adx) = + ferro_ta_core::momentum::adx_all(high, low, close, period); + Ok(DirectionalIndexResult { plus_di, minus_di, adx }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_adx() { + // Generate some trending data + let n = 50; + let high: Vec = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect(); + let low: Vec = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect(); + let close: Vec = (0..n).map(|i| 100.0 + i as f64).collect(); + + let result = adx(&high, &low, &close, 14).unwrap(); + + // ADX should be valid from index 27 (2 * period - 1) + assert!(result[26].is_nan()); + assert!(!result[27].is_nan()); + + // ADX should be positive and <= 100 + assert!(result[27] >= 0.0 && result[27] <= 100.0); + } + + #[test] + fn test_directional_movement() { + let n = 50; + let high: Vec = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect(); + let low: Vec = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect(); + let close: Vec = (0..n).map(|i| 100.0 + i as f64).collect(); + + let result = directional_movement(&high, &low, &close, 14).unwrap(); + + // Check DI values are valid + assert!(!result.plus_di[20].is_nan()); + assert!(!result.minus_di[20].is_nan()); + + // In an uptrend, +DI should be greater than -DI + assert!(result.plus_di[40] > result.minus_di[40]); + } +} \ No newline at end of file diff --git a/src/indicators/tick_features.rs b/src/indicators/tick_features.rs new file mode 100644 index 0000000..d9a6855 --- /dev/null +++ b/src/indicators/tick_features.rs @@ -0,0 +1,246 @@ +//! Tick-level feature extraction functions. +//! +//! All functions accept parallel arrays (one element per tick) and return a +//! Vec of the same length. NaN is used where the feature is undefined +//! (e.g. insufficient history for a lookback window). +//! +//! These are building blocks for the signal generation layer — compute features +//! once on the full tick window, then pass the resulting arrays to +//! `tick_signals::tick_momentum_entry`. + +/// Per-tick bid/ask spread as a percentage of the mid price. +/// +/// Returns 0.0 where both bid and ask are zero. +pub fn spread_pct(bid: &[f64], ask: &[f64]) -> Vec { + bid.iter() + .zip(ask.iter()) + .map(|(&b, &a)| { + let mid = (b + a) / 2.0; + if mid > 0.0 { + (a - b) / mid * 100.0 + } else { + 0.0 + } + }) + .collect() +} + +/// Per-tick delta BSI from Zerodha cumulative session totals. +/// +/// Zerodha's `total_buy_qty` / `total_sell_qty` are running sums that grow +/// monotonically from market open. Computing BSI from raw cumulative values +/// yields ~0.95 for the whole day (artefact of early-session buy-side dominance). +/// +/// This function computes the imbalance of the most recent tick's activity only: +/// `bsi[i] = Δbuy[i] / (Δbuy[i] + Δsell[i])` where `Δbuy[i] = max(0, buy[i] - buy[i-1])` +/// +/// Returns 0.5 (neutral) where the total delta is zero (no activity). +pub fn buy_sell_imbalance_delta( + buy_qty_cumulative: &[f64], + sell_qty_cumulative: &[f64], +) -> Vec { + let n = buy_qty_cumulative.len(); + let mut out = vec![0.5_f64; n]; + for i in 1..n { + let db = (buy_qty_cumulative[i] - buy_qty_cumulative[i - 1]).max(0.0); + let ds = (sell_qty_cumulative[i] - sell_qty_cumulative[i - 1]).max(0.0); + let total = db + ds; + if total > 0.0 { + out[i] = db / total; + } + } + out +} + +/// Per-tick lookback return over a fixed time window. +/// +/// For each tick i, finds the latest tick whose timestamp is at most +/// `timestamps_ns[i] - window_seconds * 1e9` and computes: +/// `(ltp[i] - ltp_ref) / ltp_ref * 100` +/// +/// Returns `f64::NAN` for ticks where no reference tick exists (start of series +/// or insufficient history). +/// +/// Uses binary search → O(N log N) total. +pub fn return_window(timestamps_ns: &[i64], ltp: &[f64], window_seconds: f64) -> Vec { + let n = timestamps_ns.len(); + let window_ns = (window_seconds * 1_000_000_000.0) as i64; + let mut out = vec![f64::NAN; n]; + + for i in 0..n { + let cutoff = timestamps_ns[i] - window_ns; + // Binary search for the last index with ts <= cutoff + let pos = timestamps_ns[..i].partition_point(|&ts| ts <= cutoff); + // pos is the first index > cutoff; we want pos.saturating_sub(1) + if pos > 0 { + let ref_idx = pos - 1; + let ltp_ref = ltp[ref_idx]; + if ltp_ref > 0.0 { + out[i] = (ltp[i] - ltp_ref) / ltp_ref * 100.0; + } + } + } + out +} + +/// Rolling realized volatility proxy: annualized stddev of log returns. +/// +/// For each tick i, computes stddev of log-returns over all ticks within +/// the preceding `window_seconds`. Returns `f64::NAN` if fewer than 2 ticks +/// in the window. +/// +/// O(N²) worst case but typical windows are short (60–300 s at ~80 ticks/min +/// = 80–400 ticks), making the inner loop fast in practice. +pub fn realized_vol_rolling(timestamps_ns: &[i64], ltp: &[f64], window_seconds: f64) -> Vec { + let n = timestamps_ns.len(); + let window_ns = (window_seconds * 1_000_000_000.0) as i64; + let mut out = vec![f64::NAN; n]; + + for i in 1..n { + let cutoff = timestamps_ns[i] - window_ns; + // Find the first tick inside the window + let start = timestamps_ns[..i].partition_point(|&ts| ts < cutoff); + // We need log returns from start..=i + let count = i - start; + if count < 1 { + continue; + } + let mut log_rets = Vec::with_capacity(count); + for j in (start + 1)..=i { + if ltp[j - 1] > 0.0 { + log_rets.push((ltp[j] / ltp[j - 1]).ln()); + } + } + if log_rets.len() < 2 { + continue; + } + let mean = log_rets.iter().sum::() / log_rets.len() as f64; + let variance = log_rets.iter().map(|r| (r - mean).powi(2)).sum::() + / (log_rets.len() - 1) as f64; + out[i] = variance.sqrt() * 100.0; // as percentage of price + } + out +} + +/// Per-tick OI position within the day's high/low range. +/// +/// Returns `(oi[i] - oi_day_low) / (oi_day_high - oi_day_low) * 100` ∈ [0, 100]. +/// Returns `f64::NAN` where `oi_day_high <= oi_day_low`. +pub fn oi_position_pct(oi: &[f64], oi_day_high: f64, oi_day_low: f64) -> Vec { + let range = oi_day_high - oi_day_low; + if range <= 0.0 { + return vec![f64::NAN; oi.len()]; + } + oi.iter() + .map(|&o| (o - oi_day_low) / range * 100.0) + .collect() +} + +/// Rolling tick velocity: number of ticks per minute in the preceding window. +/// +/// For each tick i, counts ticks in (timestamps_ns[i] - window_seconds*1e9, timestamps_ns[i]]. +/// Returns 0.0 for the first tick. +pub fn tick_velocity(timestamps_ns: &[i64], window_seconds: f64) -> Vec { + let n = timestamps_ns.len(); + let window_ns = (window_seconds * 1_000_000_000.0) as i64; + let mut out = vec![0.0_f64; n]; + + for i in 1..n { + let cutoff = timestamps_ns[i] - window_ns; + let start = timestamps_ns[..i].partition_point(|&ts| ts <= cutoff); + let count = (i - start + 1) as f64; // include current tick + let minutes = window_seconds / 60.0; + out[i] = if minutes > 0.0 { count / minutes } else { 0.0 }; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_spread_pct_basic() { + let bid = vec![100.0, 200.0]; + let ask = vec![101.0, 202.0]; + let s = spread_pct(&bid, &ask); + // (101-100)/100.5 * 100 ≈ 0.995 + assert!((s[0] - 0.9950248756218905).abs() < 1e-9); + // (202-200)/201 * 100 ≈ 0.995 + assert!((s[1] - 0.9950248756218905).abs() < 1e-9); + } + + #[test] + fn test_spread_pct_zero_bid_ask() { + let bid = vec![0.0]; + let ask = vec![0.0]; + let s = spread_pct(&bid, &ask); + assert_eq!(s[0], 0.0); + } + + #[test] + fn test_bsi_delta_basic() { + // Cumulative: buy grows by 100, sell by 0 → bsi = 1.0 + let buy = vec![1000.0, 1100.0, 1100.0, 1150.0]; + let sell = vec![800.0, 800.0, 850.0, 850.0]; + let bsi = buy_sell_imbalance_delta(&buy, &sell); + assert_eq!(bsi[0], 0.5); // first tick always neutral + assert_eq!(bsi[1], 1.0); // all buy + assert_eq!(bsi[2], 0.0); // all sell + assert_eq!(bsi[3], 1.0); // all buy + } + + #[test] + fn test_bsi_delta_no_activity() { + // No change → neutral 0.5 + let buy = vec![1000.0, 1000.0]; + let sell = vec![800.0, 800.0]; + let bsi = buy_sell_imbalance_delta(&buy, &sell); + assert_eq!(bsi[1], 0.5); + } + + #[test] + fn test_return_window_basic() { + // Ticks at 0s, 30s, 61s, 90s (nanoseconds) + let sec = 1_000_000_000_i64; + let ts = vec![0, 30 * sec, 61 * sec, 90 * sec]; + let ltp = vec![100.0, 102.0, 101.0, 105.0]; + let ret = return_window(&ts, <p, 60.0); + // ts[0]: no history → NAN + assert!(ret[0].is_nan()); + // ts[1] at 30s: no tick <= -30s → NAN + assert!(ret[1].is_nan()); + // ts[2] at 61s: cutoff = 1s, ts[0]=0 ≤ 1s → ref = ltp[0]=100.0 + // (101 - 100) / 100 * 100 = 1.0 + assert!((ret[2] - 1.0).abs() < 1e-9); + // ts[3] at 90s: cutoff = 30s, ts[1]=30s ≤ 30s → ref = ltp[1]=102.0 + // (105 - 102) / 102 * 100 ≈ 2.941 + assert!((ret[3] - (3.0 / 102.0 * 100.0)).abs() < 1e-9); + } + + #[test] + fn test_oi_position_pct() { + let oi = vec![50.0, 100.0, 150.0]; + let result = oi_position_pct(&oi, 200.0, 0.0); + assert_eq!(result, vec![25.0, 50.0, 75.0]); + } + + #[test] + fn test_oi_position_pct_no_range() { + let oi = vec![100.0, 100.0]; + let result = oi_position_pct(&oi, 100.0, 100.0); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + } + + #[test] + fn test_tick_velocity_basic() { + // 4 ticks at 0s, 10s, 20s, 30s; window=60s + let sec = 1_000_000_000_i64; + let ts = vec![0, 10 * sec, 20 * sec, 30 * sec]; + let vel = tick_velocity(&ts, 60.0); + // At i=3 (30s): ticks in (−30s, 30s] = all 4 → 4 ticks / 1 min = 4.0 + assert_eq!(vel[0], 0.0); + assert!((vel[3] - 4.0).abs() < 1e-9); + } +} diff --git a/src/indicators/trend.rs b/src/indicators/trend.rs new file mode 100644 index 0000000..ac021aa --- /dev/null +++ b/src/indicators/trend.rs @@ -0,0 +1,236 @@ +//! Trend indicators: SMA, EMA, Supertrend. + +use crate::core::error::RaptorError; +use crate::core::Result; + +/// Simple Moving Average. +/// +/// # Arguments +/// * `data` - Price data +/// * `period` - Lookback period +/// +/// # Returns +/// Vector of SMA values (NaN for warmup period) +pub fn sma(data: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("SMA period must be > 0")); + } + Ok(ferro_ta_core::overlap::sma(data, period)) +} + +/// Exponential Moving Average. +/// +/// # Arguments +/// * `data` - Price data +/// * `period` - Lookback period (used to calculate smoothing factor) +/// +/// # Returns +/// Vector of EMA values (NaN for warmup period) +pub fn ema(data: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("EMA period must be > 0")); + } + Ok(ferro_ta_core::overlap::ema(data, period)) +} + +/// EMA with custom smoothing factor (internal use). +#[allow(dead_code)] +pub(crate) fn ema_with_alpha(data: &[f64], alpha: f64, initial: f64) -> Vec { + let n = data.len(); + let mut result = vec![f64::NAN; n]; + + if n == 0 { + return result; + } + + result[0] = initial; + for i in 1..n { + if data[i].is_nan() { + result[i] = result[i - 1]; + } else { + result[i] = alpha * data[i] + (1.0 - alpha) * result[i - 1]; + } + } + + result +} + +/// Supertrend indicator result. +#[derive(Debug, Clone)] +pub struct SupertrendResult { + /// Supertrend line values. + pub supertrend: Vec, + /// Direction: 1 = bullish (below price), -1 = bearish (above price). + pub direction: Vec, +} + +/// Supertrend indicator. +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `period` - ATR period +/// * `multiplier` - ATR multiplier +/// +/// # Returns +/// SupertrendResult with supertrend line and direction +pub fn supertrend( + high: &[f64], + low: &[f64], + close: &[f64], + period: usize, + multiplier: f64, +) -> Result { + let n = close.len(); + if n != high.len() || n != low.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + if period == 0 { + return Err(RaptorError::invalid_parameter("Supertrend period must be > 0")); + } + + let mut supertrend = vec![f64::NAN; n]; + let mut direction = vec![0i8; n]; + + if period >= n { + return Ok(SupertrendResult { supertrend, direction }); + } + + // Calculate ATR + let atr_values = super::volatility::atr(high, low, close, period)?; + + // Calculate basic upper and lower bands + let mut upper_band = vec![f64::NAN; n]; + let mut lower_band = vec![f64::NAN; n]; + + for i in (period - 1)..n { + let hl2 = (high[i] + low[i]) / 2.0; + let atr_val = atr_values[i]; + if !atr_val.is_nan() { + upper_band[i] = hl2 + multiplier * atr_val; + lower_band[i] = hl2 - multiplier * atr_val; + } + } + + // Calculate final bands with carryover logic + let mut final_upper = vec![f64::NAN; n]; + let mut final_lower = vec![f64::NAN; n]; + + for i in (period - 1)..n { + if i == period - 1 { + final_upper[i] = upper_band[i]; + final_lower[i] = lower_band[i]; + } else { + // Final upper band: use lower of current upper or previous final upper + // if previous close was below previous final upper + if !upper_band[i].is_nan() && !final_upper[i - 1].is_nan() { + if close[i - 1] <= final_upper[i - 1] { + final_upper[i] = upper_band[i].min(final_upper[i - 1]); + } else { + final_upper[i] = upper_band[i]; + } + } else { + final_upper[i] = upper_band[i]; + } + + // Final lower band: use higher of current lower or previous final lower + // if previous close was above previous final lower + if !lower_band[i].is_nan() && !final_lower[i - 1].is_nan() { + if close[i - 1] >= final_lower[i - 1] { + final_lower[i] = lower_band[i].max(final_lower[i - 1]); + } else { + final_lower[i] = lower_band[i]; + } + } else { + final_lower[i] = lower_band[i]; + } + } + } + + // Calculate supertrend and direction + for i in (period - 1)..n { + if i == period - 1 { + // Initial direction based on price vs bands + if close[i] <= final_upper[i] { + supertrend[i] = final_upper[i]; + direction[i] = -1; // bearish + } else { + supertrend[i] = final_lower[i]; + direction[i] = 1; // bullish + } + } else { + let _prev_st = supertrend[i - 1]; + let prev_dir = direction[i - 1]; + + if prev_dir == 1 { + // Was bullish + if close[i] < final_lower[i] { + // Switch to bearish + supertrend[i] = final_upper[i]; + direction[i] = -1; + } else { + // Stay bullish + supertrend[i] = final_lower[i]; + direction[i] = 1; + } + } else { + // Was bearish + if close[i] > final_upper[i] { + // Switch to bullish + supertrend[i] = final_lower[i]; + direction[i] = 1; + } else { + // Stay bearish + supertrend[i] = final_upper[i]; + direction[i] = -1; + } + } + } + } + + Ok(SupertrendResult { supertrend, direction }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sma() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = sma(&data, 3).unwrap(); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 2.0).abs() < 1e-10); + assert!((result[3] - 3.0).abs() < 1e-10); + assert!((result[4] - 4.0).abs() < 1e-10); + } + + #[test] + fn test_ema() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = ema(&data, 3).unwrap(); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!(!result[2].is_nan()); + assert!(!result[3].is_nan()); + assert!(!result[4].is_nan()); + // EMA should be between min and max of data + assert!(result[4] >= 1.0 && result[4] <= 5.0); + } + + #[test] + fn test_sma_invalid_period() { + let data = vec![1.0, 2.0, 3.0]; + let result = sma(&data, 0); + assert!(result.is_err()); + } + + #[test] + fn test_ema_period_larger_than_data() { + let data = vec![1.0, 2.0, 3.0]; + let result = ema(&data, 10).unwrap(); + assert!(result.iter().all(|v| v.is_nan())); + } +} \ No newline at end of file diff --git a/src/indicators/volatility.rs b/src/indicators/volatility.rs new file mode 100644 index 0000000..2208ca9 --- /dev/null +++ b/src/indicators/volatility.rs @@ -0,0 +1,179 @@ +//! Volatility indicators: ATR, Bollinger Bands. + +use crate::core::error::RaptorError; +use crate::core::Result; + +/// Average True Range (ATR). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `period` - Lookback period (default: 14) +/// +/// # Returns +/// Vector of ATR values (NaN for warmup period) +pub fn atr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + let n = close.len(); + if n != high.len() || n != low.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + if period == 0 { + return Err(RaptorError::invalid_parameter("ATR period must be > 0")); + } + Ok(ferro_ta_core::volatility::atr(high, low, close, period)) +} + +/// True Range calculation (single bar). +#[inline] +pub fn true_range(high: f64, low: f64, prev_close: f64) -> f64 { + let hl = high - low; + let hc = (high - prev_close).abs(); + let lc = (low - prev_close).abs(); + hl.max(hc).max(lc) +} + +/// Bollinger Bands result. +#[derive(Debug, Clone)] +pub struct BollingerBandsResult { + /// Middle band (SMA). + pub middle: Vec, + /// Upper band (SMA + std_dev * multiplier). + pub upper: Vec, + /// Lower band (SMA - std_dev * multiplier). + pub lower: Vec, + /// Bandwidth: (upper - lower) / middle. + pub bandwidth: Vec, + /// %B: (price - lower) / (upper - lower). + pub percent_b: Vec, +} + +/// Bollinger Bands. +/// +/// # Arguments +/// * `data` - Price data (typically close prices) +/// * `period` - Lookback period (default: 20) +/// * `std_dev` - Standard deviation multiplier (default: 2.0) +/// +/// # Returns +/// BollingerBandsResult with middle, upper, lower bands, bandwidth, and %B +pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result { + if period == 0 { + return Err(RaptorError::invalid_parameter("Bollinger Bands period must be > 0")); + } + if std_dev <= 0.0 { + return Err(RaptorError::invalid_parameter("Bollinger Bands std_dev must be > 0")); + } + + let n = data.len(); + let (upper, middle, lower) = ferro_ta_core::overlap::bbands(data, period, std_dev, std_dev); + + let mut bandwidth = vec![f64::NAN; n]; + let mut percent_b = vec![f64::NAN; n]; + + for i in 0..n { + if !middle[i].is_nan() && middle[i].abs() > f64::EPSILON { + bandwidth[i] = (upper[i] - lower[i]) / middle[i].abs(); + } + let band_width = upper[i] - lower[i]; + if band_width > f64::EPSILON { + percent_b[i] = (data[i] - lower[i]) / band_width; + } + } + + Ok(BollingerBandsResult { middle, upper, lower, bandwidth, percent_b }) +} + +/// Keltner Channels (ATR-based bands). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `ema_period` - EMA period for middle band +/// * `atr_period` - ATR period +/// * `multiplier` - ATR multiplier +/// +/// # Returns +/// Tuple of (middle, upper, lower) bands +pub fn keltner_channels( + high: &[f64], + low: &[f64], + close: &[f64], + ema_period: usize, + atr_period: usize, + multiplier: f64, +) -> Result<(Vec, Vec, Vec)> { + let n = close.len(); + if n != high.len() || n != low.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + + // Calculate EMA for middle band + let middle = super::trend::ema(close, ema_period)?; + + // Calculate ATR + let atr_values = atr(high, low, close, atr_period)?; + + // Calculate bands + 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_values[i].is_nan() { + upper[i] = middle[i] + multiplier * atr_values[i]; + lower[i] = middle[i] - multiplier * atr_values[i]; + } + } + + Ok((middle, upper, lower)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_atr() { + let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5]; + let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5]; + let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0]; + + let result = atr(&high, &low, &close, 5).unwrap(); + + // ATR should be valid from index 4 + assert!(result[3].is_nan()); + assert!(!result[4].is_nan()); + assert!(result[4] > 0.0); + } + + #[test] + fn test_bollinger_bands() { + let data: Vec = (1..=30).map(|x| x as f64 + (x as f64 * 0.1).sin()).collect(); + + let result = bollinger_bands(&data, 20, 2.0).unwrap(); + + // Bands should be valid from index 19 + assert!(result.middle[18].is_nan()); + assert!(!result.middle[19].is_nan()); + + // Upper > Middle > Lower + assert!(result.upper[19] > result.middle[19]); + assert!(result.middle[19] > result.lower[19]); + + // %B should be between 0 and 1 for data within bands + assert!(result.percent_b[19] >= -0.5 && result.percent_b[19] <= 1.5); + } + + #[test] + fn test_true_range() { + // Simple case + assert!((true_range(52.0, 48.0, 50.0) - 4.0).abs() < 1e-10); + + // Gap up case + assert!((true_range(55.0, 53.0, 50.0) - 5.0).abs() < 1e-10); + + // Gap down case + assert!((true_range(48.0, 45.0, 50.0) - 5.0).abs() < 1e-10); + } +} \ No newline at end of file diff --git a/src/indicators/volume.rs b/src/indicators/volume.rs new file mode 100644 index 0000000..87c52b9 --- /dev/null +++ b/src/indicators/volume.rs @@ -0,0 +1,249 @@ +//! Volume indicators: VWAP, OBV. + +use crate::core::error::RaptorError; +use crate::core::Result; + +/// Volume Weighted Average Price (VWAP). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `volume` - Volume data +/// +/// # Returns +/// Vector of VWAP values +pub fn vwap(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result> { + let n = close.len(); + if n != high.len() || n != low.len() || n != volume.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + + if n == 0 { + return Ok(vec![]); + } + + let mut result = vec![f64::NAN; n]; + let mut cumulative_tp_vol = 0.0; + let mut cumulative_vol = 0.0; + + for i in 0..n { + // Typical price + let tp = (high[i] + low[i] + close[i]) / 3.0; + + cumulative_tp_vol += tp * volume[i]; + cumulative_vol += volume[i]; + + if cumulative_vol > 0.0 { + result[i] = cumulative_tp_vol / cumulative_vol; + } + } + + Ok(result) +} + +/// VWAP with session reset (e.g., daily reset). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `volume` - Volume data +/// * `session_starts` - Boolean array indicating session start (true = reset VWAP) +/// +/// # Returns +/// Vector of VWAP values with session resets +pub fn vwap_session( + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + session_starts: &[bool], +) -> Result> { + let n = close.len(); + if n != high.len() || n != low.len() || n != volume.len() || n != session_starts.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + + if n == 0 { + return Ok(vec![]); + } + + let mut result = vec![f64::NAN; n]; + let mut cumulative_tp_vol = 0.0; + let mut cumulative_vol = 0.0; + + for i in 0..n { + // Reset on session start + if session_starts[i] { + cumulative_tp_vol = 0.0; + cumulative_vol = 0.0; + } + + // Typical price + let tp = (high[i] + low[i] + close[i]) / 3.0; + + cumulative_tp_vol += tp * volume[i]; + cumulative_vol += volume[i]; + + if cumulative_vol > 0.0 { + result[i] = cumulative_tp_vol / cumulative_vol; + } + } + + Ok(result) +} + +/// On Balance Volume (OBV). +/// +/// # Arguments +/// * `close` - Close prices +/// * `volume` - Volume data +/// +/// # Returns +/// Vector of OBV values +pub fn obv(close: &[f64], volume: &[f64]) -> Result> { + let n = close.len(); + if n != volume.len() { + return Err(RaptorError::length_mismatch(n, volume.len())); + } + Ok(ferro_ta_core::volume::obv(close, volume)) +} + +/// Volume Rate of Change. +/// +/// # Arguments +/// * `volume` - Volume data +/// * `period` - Lookback period +/// +/// # Returns +/// Vector of volume rate of change values +pub fn volume_roc(volume: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("Period must be > 0")); + } + + let n = volume.len(); + let mut result = vec![f64::NAN; n]; + + if period >= n { + return Ok(result); + } + + for i in period..n { + if volume[i - period] != 0.0 { + result[i] = (volume[i] - volume[i - period]) / volume[i - period] * 100.0; + } + } + + Ok(result) +} + +/// Money Flow Index (volume-weighted RSI). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `volume` - Volume data +/// * `period` - Lookback period (default: 14) +/// +/// # Returns +/// Vector of MFI values (0-100 scale) +pub fn mfi( + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + period: usize, +) -> Result> { + let n = close.len(); + if n != high.len() || n != low.len() || n != volume.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + if period == 0 { + return Err(RaptorError::invalid_parameter("MFI period must be > 0")); + } + Ok(ferro_ta_core::volume::mfi(high, low, close, volume, period)) +} + +/// Accumulation/Distribution Line. +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `volume` - Volume data +/// +/// # Returns +/// Vector of A/D line values +pub fn ad_line(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result> { + let n = close.len(); + if n != high.len() || n != low.len() || n != volume.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + Ok(ferro_ta_core::volume::ad(high, low, close, volume)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vwap() { + let high = vec![52.0, 53.0, 54.0, 53.0, 52.0]; + let low = vec![50.0, 51.0, 52.0, 51.0, 50.0]; + let close = vec![51.0, 52.0, 53.0, 52.0, 51.0]; + let volume = vec![1000.0, 1500.0, 2000.0, 1500.0, 1000.0]; + + let result = vwap(&high, &low, &close, &volume).unwrap(); + + // VWAP should be valid for all bars + assert!(!result[0].is_nan()); + assert!(!result[4].is_nan()); + + // VWAP should be between low and high range + assert!(result[4] >= 50.0 && result[4] <= 54.0); + } + + #[test] + fn test_obv() { + let close = vec![50.0, 51.0, 50.5, 52.0, 51.0]; + let volume = vec![1000.0, 1500.0, 1200.0, 1800.0, 1300.0]; + + let result = obv(&close, &volume).unwrap(); + + // OBV starts with first volume + assert!((result[0] - 1000.0).abs() < 1e-10); + + // Price up -> add volume + assert!((result[1] - 2500.0).abs() < 1e-10); + + // Price down -> subtract volume + assert!((result[2] - 1300.0).abs() < 1e-10); + } + + #[test] + fn test_mfi() { + let high = vec![ + 52.0, 53.0, 54.0, 53.0, 52.0, 53.0, 54.0, 55.0, 54.0, 53.0, 52.0, 53.0, 54.0, 55.0, + 56.0, + ]; + let low = vec![ + 50.0, 51.0, 52.0, 51.0, 50.0, 51.0, 52.0, 53.0, 52.0, 51.0, 50.0, 51.0, 52.0, 53.0, + 54.0, + ]; + let close = vec![ + 51.0, 52.0, 53.0, 52.0, 51.0, 52.0, 53.0, 54.0, 53.0, 52.0, 51.0, 52.0, 53.0, 54.0, + 55.0, + ]; + let volume = vec![1000.0; 15]; + + let result = mfi(&high, &low, &close, &volume, 14).unwrap(); + + // MFI should be valid from index 14 + assert!(result[13].is_nan()); + assert!(!result[14].is_nan()); + assert!(result[14] >= 0.0 && result[14] <= 100.0); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6cbac98 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,160 @@ +// Suppress warning from PyO3 macro expansion (fixed in newer PyO3 versions) +#![allow(non_local_definitions)] + +//! RaptorBT - High-performance Rust backtesting engine. +//! +//! This crate provides a complete backtesting solution with: +//! - Technical indicators (SMA, EMA, RSI, MACD, etc.) +//! - Portfolio simulation engine +//! - Multiple strategy types (single, basket, options, pairs, multi) +//! - Stop-loss and take-profit mechanisms +//! - Streaming metrics calculation + +use pyo3::prelude::*; + +pub mod core; +pub mod execution; +pub mod indicators; +pub mod metrics; +pub mod portfolio; +pub mod python; +pub mod signals; +pub mod stops; +pub mod strategies; + +/// Python module entry point +#[pymodule] +fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + // Register config classes + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + // Register result classes + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + // Register backtest functions + m.add_function(wrap_pyfunction!(python::bindings::run_single_backtest, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::run_basket_backtest, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::run_options_backtest, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::run_pairs_backtest, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::run_multi_backtest, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::run_spread_backtest, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::run_tick_backtest, m)?)?; + + // Register batch spread backtest + m.add_class::()?; + m.add_function(wrap_pyfunction!(python::bindings::batch_spread_backtest, m)?)?; + + // Register Monte Carlo simulation + m.add_function(wrap_pyfunction!(python::bindings::simulate_portfolio_mc, m)?)?; + + // Register tick signal functions + m.add_function(wrap_pyfunction!(python::bindings::compute_tick_entry_signals, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::compute_tick_exit_signals, m)?)?; + + // Register tick feature functions + m.add_function(wrap_pyfunction!(python::bindings::tick_spread_pct, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::buy_sell_imbalance_delta, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::return_window, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::realized_vol_rolling, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::oi_position_pct, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::tick_velocity, m)?)?; + + // Register indicator functions + m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::rsi, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::macd, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::stochastic, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::atr, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::bollinger_bands, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::adx, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::vwap, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::supertrend, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::rolling_min, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::rolling_max, m)?)?; + + // Extended indicators (via ferro_ta_core) + m.add_function(wrap_pyfunction!(python::bindings::cci, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::willr, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::sar, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::plus_di, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::minus_di, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::adx_all, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::adxr, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::roc, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::mfi, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::wma, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::dema, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::tema, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::kama, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::stochrsi, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::aroon, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::trix, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::natr, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::trange, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::stddev, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::var, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::linearreg, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::linearreg_slope, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::linearreg_intercept, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::linearreg_angle, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::tsf, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::beta, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::correl, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ad, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::adosc, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::obv, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::mom, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ppo, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::cmo, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::aroonosc, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::bop, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ultosc, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::typprice, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::medprice, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::avgprice, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::wclprice, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::midpoint, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::midprice, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::t3, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::trima, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::apo, m)?)?; + + // P0 batch — Extended (donchian / hull_ma / ichimoku / pivot_points / etc.) + m.add_function(wrap_pyfunction!(python::bindings::vwma, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::donchian, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::choppiness_index, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::hull_ma, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::chandelier_exit, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ichimoku, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::pivot_points, m)?)?; + + // P0 batch — Hilbert Transform (cycle) + m.add_function(wrap_pyfunction!(python::bindings::ht_trendline, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ht_dcperiod, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ht_dcphase, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ht_phasor, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ht_sine, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ht_trendmode, m)?)?; + + // P0 batch — Market regime detection + m.add_function(wrap_pyfunction!(python::bindings::regime_adx, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::regime_combined, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::detect_breaks_cusum, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::rolling_variance_break, m)?)?; + + // P0 batch — Portfolio / cross-series tools + m.add_function(wrap_pyfunction!(python::bindings::rolling_beta, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::drawdown_series, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::zscore_series, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::relative_strength, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::spread, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::ratio, m)?)?; + + Ok(()) +} \ No newline at end of file diff --git a/src/metrics/drawdown.rs b/src/metrics/drawdown.rs new file mode 100644 index 0000000..481cdf6 --- /dev/null +++ b/src/metrics/drawdown.rs @@ -0,0 +1,344 @@ +//! Incremental drawdown tracking. + +/// Drawdown tracker for incremental portfolio value updates. +#[derive(Debug, Clone)] +pub struct DrawdownTracker { + /// Current peak value. + peak: f64, + /// Current drawdown value. + current_drawdown: f64, + /// Maximum drawdown seen. + max_drawdown: f64, + /// Current drawdown duration (bars since peak). + current_duration: usize, + /// Maximum drawdown duration. + max_duration: usize, + /// Value at drawdown start. + drawdown_start_value: f64, + /// Index at drawdown start. + drawdown_start_idx: usize, + /// Index at max drawdown. + max_drawdown_idx: usize, + /// Total count of updates. + count: usize, +} + +impl Default for DrawdownTracker { + fn default() -> Self { + Self::new() + } +} + +impl DrawdownTracker { + /// Create a new drawdown tracker. + pub fn new() -> Self { + Self { + peak: 0.0, + current_drawdown: 0.0, + max_drawdown: 0.0, + current_duration: 0, + max_duration: 0, + drawdown_start_value: 0.0, + drawdown_start_idx: 0, + max_drawdown_idx: 0, + count: 0, + } + } + + /// Create with initial value. + pub fn with_initial(initial_value: f64) -> Self { + Self { + peak: initial_value, + current_drawdown: 0.0, + max_drawdown: 0.0, + current_duration: 0, + max_duration: 0, + drawdown_start_value: initial_value, + drawdown_start_idx: 0, + max_drawdown_idx: 0, + count: 1, + } + } + + /// Update with new portfolio value. + pub fn update(&mut self, value: f64) { + self.count += 1; + + if value > self.peak { + // New peak - reset drawdown + self.peak = value; + self.current_drawdown = 0.0; + self.current_duration = 0; + self.drawdown_start_value = value; + self.drawdown_start_idx = self.count - 1; + } else { + // In drawdown + self.current_drawdown = (self.peak - value) / self.peak; + self.current_duration += 1; + + if self.current_drawdown > self.max_drawdown { + self.max_drawdown = self.current_drawdown; + self.max_drawdown_idx = self.count - 1; + } + + if self.current_duration > self.max_duration { + self.max_duration = self.current_duration; + } + } + } + + /// Get current drawdown as percentage. + #[inline] + pub fn current_drawdown_pct(&self) -> f64 { + self.current_drawdown * 100.0 + } + + /// Get maximum drawdown as percentage. + #[inline] + pub fn max_drawdown_pct(&self) -> f64 { + self.max_drawdown * 100.0 + } + + /// Get maximum drawdown as fraction. + #[inline] + pub fn max_drawdown(&self) -> f64 { + self.max_drawdown + } + + /// Get current peak value. + #[inline] + pub fn peak(&self) -> f64 { + self.peak + } + + /// Get current drawdown duration. + #[inline] + pub fn current_duration(&self) -> usize { + self.current_duration + } + + /// Get maximum drawdown duration. + #[inline] + pub fn max_duration(&self) -> usize { + self.max_duration + } + + /// Check if currently in drawdown. + #[inline] + pub fn in_drawdown(&self) -> bool { + self.current_drawdown > 0.0 + } + + /// Get index where max drawdown occurred. + #[inline] + pub fn max_drawdown_idx(&self) -> usize { + self.max_drawdown_idx + } + + /// Reset the tracker. + pub fn reset(&mut self) { + *self = Self::new(); + } +} + +/// Calculate drawdown curve from equity curve. +/// +/// # Arguments +/// * `equity_curve` - Portfolio values over time +/// +/// # Returns +/// Drawdown percentages at each point +pub fn calculate_drawdown_curve(equity_curve: &[f64]) -> Vec { + let n = equity_curve.len(); + if n == 0 { + return vec![]; + } + + let mut drawdown_curve = vec![0.0; n]; + let mut peak = equity_curve[0]; + + for i in 0..n { + if equity_curve[i] > peak { + peak = equity_curve[i]; + } + if peak > 0.0 { + drawdown_curve[i] = (peak - equity_curve[i]) / peak * 100.0; + } + } + + drawdown_curve +} + +/// Calculate maximum drawdown from equity curve. +/// +/// # Arguments +/// * `equity_curve` - Portfolio values over time +/// +/// # Returns +/// Maximum drawdown as percentage +pub fn max_drawdown(equity_curve: &[f64]) -> f64 { + let dd = calculate_drawdown_curve(equity_curve); + dd.iter().fold(0.0f64, |a, &b| a.max(b)) +} + +/// Calculate average drawdown from equity curve. +/// +/// # Arguments +/// * `equity_curve` - Portfolio values over time +/// +/// # Returns +/// Average drawdown as percentage +pub fn avg_drawdown(equity_curve: &[f64]) -> f64 { + let dd = calculate_drawdown_curve(equity_curve); + if dd.is_empty() { + return 0.0; + } + dd.iter().sum::() / dd.len() as f64 +} + +/// Find drawdown periods. +/// +/// # Arguments +/// * `equity_curve` - Portfolio values over time +/// +/// # Returns +/// Vector of (start_idx, end_idx, max_drawdown) tuples for each drawdown period +pub fn drawdown_periods(equity_curve: &[f64]) -> Vec<(usize, usize, f64)> { + let n = equity_curve.len(); + if n < 2 { + return vec![]; + } + + let mut periods = Vec::new(); + let mut peak = equity_curve[0]; + let mut peak_idx = 0; + let mut in_dd = false; + let mut dd_start = 0; + let mut max_dd = 0.0; + + for i in 1..n { + if equity_curve[i] > peak { + if in_dd { + // End of drawdown period + periods.push((dd_start, i - 1, max_dd)); + in_dd = false; + max_dd = 0.0; + } + peak = equity_curve[i]; + peak_idx = i; + } else if peak > 0.0 { + let dd = (peak - equity_curve[i]) / peak * 100.0; + if !in_dd && dd > 0.0 { + in_dd = true; + dd_start = peak_idx; + } + if dd > max_dd { + max_dd = dd; + } + } + } + + // Handle ongoing drawdown at end + if in_dd { + periods.push((dd_start, n - 1, max_dd)); + } + + periods +} + +/// Calculate Calmar ratio. +/// +/// # Arguments +/// * `total_return` - Total return as percentage +/// * `max_drawdown` - Maximum drawdown as percentage +/// +/// # Returns +/// Calmar ratio +pub fn calmar_ratio(total_return: f64, max_drawdown: f64) -> f64 { + if max_drawdown <= 0.0 { + return if total_return > 0.0 { f64::INFINITY } else { 0.0 }; + } + total_return / max_drawdown +} + +/// Calculate Ulcer Index (root mean square of drawdowns). +/// +/// # Arguments +/// * `equity_curve` - Portfolio values over time +/// +/// # Returns +/// Ulcer Index +pub fn ulcer_index(equity_curve: &[f64]) -> f64 { + let dd = calculate_drawdown_curve(equity_curve); + if dd.is_empty() { + return 0.0; + } + let sum_sq: f64 = dd.iter().map(|d| d * d).sum(); + (sum_sq / dd.len() as f64).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_tracking() { + let mut tracker = DrawdownTracker::new(); + + tracker.update(100.0); + tracker.update(110.0); + tracker.update(105.0); // 4.5% drawdown + tracker.update(120.0); + tracker.update(100.0); // 16.67% drawdown + + assert!((tracker.max_drawdown_pct() - 16.67).abs() < 0.1); + assert!((tracker.peak() - 120.0).abs() < 1e-10); + } + + #[test] + fn test_drawdown_curve() { + let equity = vec![100.0, 110.0, 105.0, 120.0, 100.0]; + let dd = calculate_drawdown_curve(&equity); + + assert_eq!(dd.len(), 5); + assert!((dd[0] - 0.0).abs() < 1e-10); + assert!((dd[1] - 0.0).abs() < 1e-10); + assert!((dd[2] - 4.545).abs() < 0.1); // (110-105)/110 * 100 + assert!((dd[3] - 0.0).abs() < 1e-10); + assert!((dd[4] - 16.67).abs() < 0.1); // (120-100)/120 * 100 + } + + #[test] + fn test_max_drawdown() { + let equity = vec![100.0, 120.0, 90.0, 110.0, 85.0]; + let max_dd = max_drawdown(&equity); + + // Max DD should be (120-85)/120 = 29.17% + assert!((max_dd - 29.17).abs() < 0.1); + } + + #[test] + fn test_drawdown_periods() { + let equity = vec![100.0, 110.0, 105.0, 115.0, 100.0, 120.0]; + let periods = drawdown_periods(&equity); + + // Should have 2 drawdown periods + assert_eq!(periods.len(), 2); + } + + #[test] + fn test_calmar_ratio() { + // 50% return with 10% max drawdown + let calmar = calmar_ratio(50.0, 10.0); + assert!((calmar - 5.0).abs() < 1e-10); + } + + #[test] + fn test_ulcer_index() { + let equity = vec![100.0, 95.0, 90.0, 95.0, 100.0]; + let ui = ulcer_index(&equity); + + // Should be positive (there were drawdowns) + assert!(ui > 0.0); + } +} diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs new file mode 100644 index 0000000..a45da02 --- /dev/null +++ b/src/metrics/mod.rs @@ -0,0 +1,9 @@ +//! Performance metrics for RaptorBT. + +pub mod drawdown; +pub mod streaming; +pub mod trade_stats; + +pub use drawdown::DrawdownTracker; +pub use streaming::StreamingMetrics; +pub use trade_stats::TradeStatistics; diff --git a/src/metrics/streaming.rs b/src/metrics/streaming.rs new file mode 100644 index 0000000..1e40678 --- /dev/null +++ b/src/metrics/streaming.rs @@ -0,0 +1,756 @@ +//! Streaming metrics calculation using Welford's algorithm. +//! +//! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio. + +use crate::core::types::BacktestMetrics; + +/// Streaming metrics calculator using Welford's algorithm. +/// +/// Allows incremental calculation of statistics without storing all values. +/// Also tracks equity and drawdown for backtesting. +#[derive(Debug, Clone)] +pub struct StreamingMetrics { + /// Number of observations. + count: usize, + /// Running mean. + mean: f64, + /// Running M2 for variance calculation. + m2: f64, + /// Running M2 for downside variance (Sortino). + m2_downside: f64, + /// Target return for Sortino (default: 0). + target_return: f64, + /// Sum of returns (for total return calculation). + sum: f64, + /// Sum of positive returns. + sum_positive: f64, + /// Sum of negative returns. + sum_negative: f64, + /// Count of positive returns. + count_positive: usize, + /// Count of negative returns. + count_negative: usize, + + // === Equity and drawdown tracking === + /// Initial capital. + #[allow(dead_code)] + initial_capital: f64, + /// Peak equity value (for drawdown calculation). + peak_equity: f64, + /// Current equity value. + current_equity: f64, + /// Maximum drawdown percentage. + max_drawdown_pct: f64, + /// Current drawdown percentage. + current_drawdown: f64, + /// Bars since peak (for max drawdown duration). + bars_since_peak: usize, + /// Maximum drawdown duration in bars. + max_drawdown_duration: usize, + + // === Trade tracking === + /// Number of trades. + trade_count: usize, + /// Number of winning trades. + winning_trades: usize, + /// Number of losing trades. + losing_trades: usize, + /// Sum of winning trade P&L. + sum_wins: f64, + /// Sum of losing trade P&L. + sum_losses: f64, + /// Sum of trade return percentages. + sum_trade_returns: f64, + /// Sum of squared trade return percentages (for SQN). + sum_trade_returns_sq: f64, + /// Best trade return percentage. + best_trade_pct: f64, + /// Worst trade return percentage. + worst_trade_pct: f64, + /// Sum of winning trade durations. + sum_winning_duration: usize, + /// Sum of losing trade durations. + sum_losing_duration: usize, + /// Current consecutive wins. + current_consecutive_wins: usize, + /// Current consecutive losses. + current_consecutive_losses: usize, + /// Maximum consecutive wins. + max_consecutive_wins: usize, + /// Maximum consecutive losses. + max_consecutive_losses: usize, + /// Total holding period (bars). + total_holding_period: usize, + /// Total fees paid. + total_fees: f64, +} + +impl Default for StreamingMetrics { + fn default() -> Self { + Self::new() + } +} + +impl StreamingMetrics { + /// Create a new streaming metrics calculator. + pub fn new() -> Self { + Self::with_initial_capital(0.0) + } + + /// Create a new streaming metrics calculator with initial capital. + pub fn with_initial_capital(initial_capital: f64) -> Self { + Self { + count: 0, + mean: 0.0, + m2: 0.0, + m2_downside: 0.0, + target_return: 0.0, + sum: 0.0, + sum_positive: 0.0, + sum_negative: 0.0, + count_positive: 0, + count_negative: 0, + // Equity tracking + initial_capital, + peak_equity: initial_capital, + current_equity: initial_capital, + max_drawdown_pct: 0.0, + current_drawdown: 0.0, + bars_since_peak: 0, + max_drawdown_duration: 0, + // Trade tracking + trade_count: 0, + winning_trades: 0, + losing_trades: 0, + sum_wins: 0.0, + sum_losses: 0.0, + sum_trade_returns: 0.0, + sum_trade_returns_sq: 0.0, + best_trade_pct: f64::NEG_INFINITY, + worst_trade_pct: f64::INFINITY, + sum_winning_duration: 0, + sum_losing_duration: 0, + current_consecutive_wins: 0, + current_consecutive_losses: 0, + max_consecutive_wins: 0, + max_consecutive_losses: 0, + total_holding_period: 0, + total_fees: 0.0, + } + } + + /// Create with a custom target return for Sortino calculation. + pub fn with_target_return(mut self, target: f64) -> Self { + self.target_return = target; + self + } + + /// Update metrics with a new return value. + /// + /// Uses Welford's online algorithm for numerically stable variance calculation. + pub fn update(&mut self, return_value: f64) { + self.count += 1; + self.sum += return_value; + + // Track positive/negative + if return_value > 0.0 { + self.sum_positive += return_value; + self.count_positive += 1; + } else if return_value < 0.0 { + self.sum_negative += return_value; + self.count_negative += 1; + } + + // Welford's algorithm for mean and variance + let delta = return_value - self.mean; + self.mean += delta / self.count as f64; + let delta2 = return_value - self.mean; + self.m2 += delta * delta2; + + // Downside variance (for Sortino) + let downside = (return_value - self.target_return).min(0.0); + let _delta_down = downside - (self.m2_downside / self.count.max(1) as f64).sqrt(); + self.m2_downside += downside * downside; + } + + /// Get the number of observations. + #[inline] + pub fn count(&self) -> usize { + self.count + } + + /// Get the running mean. + #[inline] + pub fn mean(&self) -> f64 { + self.mean + } + + /// Get the sample variance. + pub fn variance(&self) -> f64 { + if self.count < 2 { + return 0.0; + } + self.m2 / (self.count - 1) as f64 + } + + /// Get the population variance. + pub fn variance_population(&self) -> f64 { + if self.count == 0 { + return 0.0; + } + self.m2 / self.count as f64 + } + + /// Get the sample standard deviation. + pub fn std_dev(&self) -> f64 { + self.variance().sqrt() + } + + /// Get the downside standard deviation (for Sortino). + pub fn downside_std_dev(&self) -> f64 { + if self.count < 2 { + return 0.0; + } + (self.m2_downside / (self.count - 1) as f64).sqrt() + } + + /// Calculate Sharpe ratio. + /// + /// # Arguments + /// * `periods_per_year` - Number of periods per year (e.g., 252 for daily) + /// * `risk_free_rate` - Annual risk-free rate (default: 0) + /// + /// # Returns + /// Annualized Sharpe ratio + pub fn sharpe_ratio(&self, periods_per_year: f64) -> f64 { + self.sharpe_ratio_with_rf(periods_per_year, 0.0) + } + + /// Calculate Sharpe ratio with custom risk-free rate. + pub fn sharpe_ratio_with_rf(&self, periods_per_year: f64, risk_free_rate: f64) -> f64 { + let std = self.std_dev(); + if std == 0.0 || self.count < 2 { + return 0.0; + } + + let rf_per_period = risk_free_rate / periods_per_year; + let excess_return = self.mean - rf_per_period; + let annualized_excess = excess_return * periods_per_year; + let annualized_std = std * periods_per_year.sqrt(); + + annualized_excess / annualized_std + } + + /// Calculate Sortino ratio. + /// + /// # Arguments + /// * `periods_per_year` - Number of periods per year (e.g., 252 for daily) + /// + /// # Returns + /// Annualized Sortino ratio + pub fn sortino_ratio(&self, periods_per_year: f64) -> f64 { + let downside_std = self.downside_std_dev(); + if downside_std == 0.0 || self.count < 2 { + return if self.mean > 0.0 { f64::INFINITY } else { 0.0 }; + } + + let excess_return = self.mean - self.target_return; + let annualized_excess = excess_return * periods_per_year; + let annualized_downside_std = downside_std * periods_per_year.sqrt(); + + annualized_excess / annualized_downside_std + } + + /// Get total return. + pub fn total_return(&self) -> f64 { + self.sum + } + + /// Get average positive return. + pub fn avg_positive_return(&self) -> f64 { + if self.count_positive == 0 { + return 0.0; + } + self.sum_positive / self.count_positive as f64 + } + + /// Get average negative return. + pub fn avg_negative_return(&self) -> f64 { + if self.count_negative == 0 { + return 0.0; + } + self.sum_negative / self.count_negative as f64 + } + + /// Get win rate (fraction of positive returns). + pub fn win_rate(&self) -> f64 { + if self.count == 0 { + return 0.0; + } + self.count_positive as f64 / self.count as f64 + } + + /// Get profit factor (sum of profits / sum of losses). + pub fn profit_factor(&self) -> f64 { + if self.sum_negative == 0.0 { + return if self.sum_positive > 0.0 { f64::INFINITY } else { 0.0 }; + } + self.sum_positive / self.sum_negative.abs() + } + + /// Get omega ratio (same as profit factor for return-based calculation). + /// Omega = (sum of returns above threshold) / |sum of returns below threshold| + /// With threshold = 0, this equals profit_factor. + pub fn omega_ratio(&self) -> f64 { + self.profit_factor() + } + + // === Equity tracking methods === + + /// Update equity and calculate drawdown. + pub fn update_equity(&mut self, equity: f64) { + self.current_equity = equity; + + if equity > self.peak_equity { + self.peak_equity = equity; + self.bars_since_peak = 0; + } else { + self.bars_since_peak += 1; + if self.bars_since_peak > self.max_drawdown_duration { + self.max_drawdown_duration = self.bars_since_peak; + } + } + + // Calculate current drawdown percentage + if self.peak_equity > 0.0 { + self.current_drawdown = (self.peak_equity - equity) / self.peak_equity * 100.0; + if self.current_drawdown > self.max_drawdown_pct { + self.max_drawdown_pct = self.current_drawdown; + } + } + } + + /// Get current drawdown percentage. + #[inline] + pub fn current_drawdown_pct(&self) -> f64 { + self.current_drawdown + } + + /// Get maximum drawdown percentage. + #[inline] + pub fn max_drawdown_pct(&self) -> f64 { + self.max_drawdown_pct + } + + // === Trade tracking methods === + + /// Record a completed trade. + /// + /// # Arguments + /// * `pnl` - Trade profit/loss + /// * `return_pct` - Trade return percentage + /// * `duration` - Trade duration in bars + pub fn record_trade(&mut self, pnl: f64, return_pct: f64, duration: usize) { + self.trade_count += 1; + self.sum_trade_returns += return_pct; + self.sum_trade_returns_sq += return_pct * return_pct; + self.total_holding_period += duration; + + // Track best/worst trades + if return_pct > self.best_trade_pct { + self.best_trade_pct = return_pct; + } + if return_pct < self.worst_trade_pct { + self.worst_trade_pct = return_pct; + } + + if pnl > 0.0 { + self.winning_trades += 1; + self.sum_wins += pnl; + self.sum_winning_duration += duration; + self.current_consecutive_wins += 1; + self.current_consecutive_losses = 0; + if self.current_consecutive_wins > self.max_consecutive_wins { + self.max_consecutive_wins = self.current_consecutive_wins; + } + } else if pnl < 0.0 { + self.losing_trades += 1; + self.sum_losses += pnl.abs(); + self.sum_losing_duration += duration; + self.current_consecutive_losses += 1; + self.current_consecutive_wins = 0; + if self.current_consecutive_losses > self.max_consecutive_losses { + self.max_consecutive_losses = self.current_consecutive_losses; + } + } + } + + /// Record fees paid. + pub fn record_fees(&mut self, fees: f64) { + self.total_fees += fees; + } + + /// Finalize metrics and produce BacktestMetrics. + /// + /// # Arguments + /// * `initial_capital` - Starting capital + /// * `final_value` - Ending portfolio value + /// * `returns` - Array of period returns for ratio calculations + pub fn finalize( + &self, + initial_capital: f64, + final_value: f64, + returns: &[f64], + ) -> BacktestMetrics { + // Calculate return metrics from the returns array + let mut return_metrics = StreamingMetrics::new(); + for &r in returns { + if !r.is_nan() { + return_metrics.update(r); + } + } + + let total_return_pct = if initial_capital > 0.0 { + (final_value - initial_capital) / initial_capital * 100.0 + } else { + 0.0 + }; + + // Calculate trade-based metrics + let win_rate_pct = if self.trade_count > 0 { + self.winning_trades as f64 / self.trade_count as f64 * 100.0 + } else { + 0.0 + }; + + let profit_factor = if self.sum_losses > 0.0 { + self.sum_wins / self.sum_losses + } else if self.sum_wins > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + let avg_trade_return_pct = if self.trade_count > 0 { + self.sum_trade_returns / self.trade_count as f64 + } else { + 0.0 + }; + + let avg_win_pct = if self.winning_trades > 0 { + self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0 + } else { + 0.0 + }; + + let avg_loss_pct = if self.losing_trades > 0 { + -(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0) + } else { + 0.0 + }; + + let avg_winning_duration = if self.winning_trades > 0 { + self.sum_winning_duration as f64 / self.winning_trades as f64 + } else { + 0.0 + }; + + let avg_losing_duration = if self.losing_trades > 0 { + self.sum_losing_duration as f64 / self.losing_trades as f64 + } else { + 0.0 + }; + + let avg_holding_period = if self.trade_count > 0 { + self.total_holding_period as f64 / self.trade_count as f64 + } else { + 0.0 + }; + + // Expectancy: average profit per trade + let expectancy = if self.trade_count > 0 { + (self.sum_wins - self.sum_losses) / self.trade_count as f64 + } else { + 0.0 + }; + + // SQN (System Quality Number) + let sqn = if self.trade_count > 1 { + let mean_return = self.sum_trade_returns / self.trade_count as f64; + let variance = + (self.sum_trade_returns_sq / self.trade_count as f64) - (mean_return * mean_return); + let std_dev = variance.max(0.0).sqrt(); + if std_dev > 0.0 { + (mean_return / std_dev) * (self.trade_count as f64).sqrt() + } else { + 0.0 + } + } else { + 0.0 + }; + + // Sharpe ratio (annualized, assuming 252 trading days) + let sharpe_ratio = return_metrics.sharpe_ratio(252.0); + + // Sortino ratio (annualized) + let sortino_ratio = return_metrics.sortino_ratio(252.0); + + // Calmar ratio (annualized return / max drawdown) + let calmar_ratio = if self.max_drawdown_pct > 0.0 { + total_return_pct / self.max_drawdown_pct + } else if total_return_pct > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Omega ratio + let omega_ratio = return_metrics.omega_ratio(); + + // Best/worst trade handling (handle edge cases) + let best_trade_pct = + if self.best_trade_pct == f64::NEG_INFINITY { 0.0 } else { self.best_trade_pct }; + let worst_trade_pct = + if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct }; + + // Payoff ratio: average win / average loss (absolute value) + let payoff_ratio = if avg_loss_pct.abs() > 0.0 { + avg_win_pct / avg_loss_pct.abs() + } else if avg_win_pct > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Recovery factor: net profit / max drawdown (absolute value) + let net_profit = final_value - initial_capital; + let recovery_factor = if self.max_drawdown_pct > 0.0 && initial_capital > 0.0 { + let max_dd_absolute = self.max_drawdown_pct / 100.0 * initial_capital; + if max_dd_absolute > 0.0 { + net_profit / max_dd_absolute + } else { + 0.0 + } + } else if net_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + BacktestMetrics { + total_return_pct, + sharpe_ratio, + sortino_ratio, + calmar_ratio, + omega_ratio, + max_drawdown_pct: self.max_drawdown_pct, + max_drawdown_duration: self.max_drawdown_duration, + win_rate_pct, + profit_factor, + expectancy, + sqn, + total_trades: self.trade_count, + total_closed_trades: self.trade_count, + total_open_trades: 0, + open_trade_pnl: 0.0, + winning_trades: self.winning_trades, + losing_trades: self.losing_trades, + start_value: initial_capital, + end_value: final_value, + total_fees_paid: self.total_fees, + best_trade_pct, + worst_trade_pct, + avg_trade_return_pct, + avg_win_pct, + avg_loss_pct, + avg_winning_duration, + avg_losing_duration, + max_consecutive_wins: self.max_consecutive_wins, + max_consecutive_losses: self.max_consecutive_losses, + avg_holding_period, + exposure_pct: 0.0, // TODO: calculate based on time in market + payoff_ratio, + recovery_factor, + } + } + + /// Reset all metrics. + pub fn reset(&mut self) { + *self = Self::new(); + } + + /// Merge two streaming metrics (for parallel computation). + pub fn merge(&mut self, other: &StreamingMetrics) { + if other.count == 0 { + return; + } + if self.count == 0 { + *self = other.clone(); + return; + } + + let combined_count = self.count + other.count; + let delta = other.mean - self.mean; + + // Merge means + let combined_mean = self.mean + delta * other.count as f64 / combined_count as f64; + + // Merge M2 (parallel variance) + let combined_m2 = self.m2 + + other.m2 + + delta * delta * self.count as f64 * other.count as f64 / combined_count as f64; + + // Update state + self.count = combined_count; + self.mean = combined_mean; + self.m2 = combined_m2; + self.sum += other.sum; + self.sum_positive += other.sum_positive; + self.sum_negative += other.sum_negative; + self.count_positive += other.count_positive; + self.count_negative += other.count_negative; + self.m2_downside += other.m2_downside; // Approximation + } +} + +/// Calculate Sharpe ratio from a slice of returns. +pub fn sharpe_ratio(returns: &[f64], periods_per_year: f64, risk_free_rate: f64) -> f64 { + let mut metrics = StreamingMetrics::new(); + for &r in returns { + if !r.is_nan() { + metrics.update(r); + } + } + metrics.sharpe_ratio_with_rf(periods_per_year, risk_free_rate) +} + +/// Calculate Sortino ratio from a slice of returns. +pub fn sortino_ratio(returns: &[f64], periods_per_year: f64) -> f64 { + let mut metrics = StreamingMetrics::new(); + for &r in returns { + if !r.is_nan() { + metrics.update(r); + } + } + metrics.sortino_ratio(periods_per_year) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_statistics() { + let mut metrics = StreamingMetrics::new(); + let values = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + + for v in &values { + metrics.update(*v); + } + + assert_eq!(metrics.count(), 5); + assert!((metrics.mean() - 3.0).abs() < 1e-10); + + // Sample variance of [1,2,3,4,5] = 2.5 + assert!((metrics.variance() - 2.5).abs() < 1e-10); + } + + #[test] + fn test_welford_numerical_stability() { + let mut metrics = StreamingMetrics::new(); + + // Large values that might cause numerical issues with naive algorithm + let base = 1e10; + let values = vec![base + 1.0, base + 2.0, base + 3.0]; + + for v in &values { + metrics.update(*v); + } + + // Mean should be base + 2 + assert!((metrics.mean() - (base + 2.0)).abs() < 1e-5); + + // Variance should be 1.0 (same as [1, 2, 3]) + assert!((metrics.variance() - 1.0).abs() < 1e-5); + } + + #[test] + fn test_sharpe_ratio() { + let mut metrics = StreamingMetrics::new(); + + // Daily returns: 1%, 2%, -1%, 1.5%, 0.5% + let returns = vec![0.01, 0.02, -0.01, 0.015, 0.005]; + + for r in &returns { + metrics.update(*r); + } + + // Should produce a positive Sharpe ratio + let sharpe = metrics.sharpe_ratio(252.0); + assert!(sharpe > 0.0); + } + + #[test] + fn test_sortino_ratio() { + let mut metrics = StreamingMetrics::new(); + + // Mix of positive and negative returns + let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01]; + + for r in &returns { + metrics.update(*r); + } + + // Sortino should be different from Sharpe + let sharpe = metrics.sharpe_ratio(252.0); + let sortino = metrics.sortino_ratio(252.0); + + // With negative returns, Sortino penalizes only downside + assert!(sortino != sharpe); + } + + #[test] + fn test_win_rate_and_profit_factor() { + let mut metrics = StreamingMetrics::new(); + + // 3 wins, 2 losses + let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01]; + + for r in &returns { + metrics.update(*r); + } + + // Win rate should be 60% + assert!((metrics.win_rate() - 0.6).abs() < 1e-10); + + // Profit factor = 0.06 / 0.03 = 2.0 + assert!((metrics.profit_factor() - 2.0).abs() < 1e-10); + } + + #[test] + fn test_merge() { + let mut m1 = StreamingMetrics::new(); + let mut m2 = StreamingMetrics::new(); + + // Split data between two calculators + for v in &[1.0, 2.0, 3.0] { + m1.update(*v); + } + for v in &[4.0, 5.0] { + m2.update(*v); + } + + // Merge + m1.merge(&m2); + + // Should match single calculator with all data + let mut combined = StreamingMetrics::new(); + for v in &[1.0, 2.0, 3.0, 4.0, 5.0] { + combined.update(*v); + } + + assert_eq!(m1.count(), combined.count()); + assert!((m1.mean() - combined.mean()).abs() < 1e-10); + assert!((m1.variance() - combined.variance()).abs() < 1e-10); + } +} diff --git a/src/metrics/trade_stats.rs b/src/metrics/trade_stats.rs new file mode 100644 index 0000000..bdec756 --- /dev/null +++ b/src/metrics/trade_stats.rs @@ -0,0 +1,350 @@ +//! Trade statistics calculation. + +use crate::core::types::Trade; + +/// Comprehensive trade statistics. +#[derive(Debug, Clone, Default)] +pub struct TradeStatistics { + /// Total number of trades. + pub total_trades: usize, + /// Number of winning trades. + pub winning_trades: usize, + /// Number of losing trades. + pub losing_trades: usize, + /// Number of breakeven trades. + pub breakeven_trades: usize, + /// Win rate (as percentage). + pub win_rate: f64, + /// Average win amount. + pub avg_win: f64, + /// Average loss amount. + pub avg_loss: f64, + /// Largest win. + pub largest_win: f64, + /// Largest loss. + pub largest_loss: f64, + /// Total profit. + pub total_profit: f64, + /// Total loss. + pub total_loss: f64, + /// Net profit. + pub net_profit: f64, + /// Profit factor. + pub profit_factor: f64, + /// Expected value per trade. + pub expectancy: f64, + /// Average trade return percentage. + pub avg_return_pct: f64, + /// Average holding period (bars). + pub avg_holding_period: f64, + /// Max consecutive wins. + pub max_consecutive_wins: usize, + /// Max consecutive losses. + pub max_consecutive_losses: usize, + /// Average win/loss ratio. + pub avg_win_loss_ratio: f64, + /// Recovery factor (net profit / max loss). + pub recovery_factor: f64, + /// Payoff ratio (avg win / avg loss). + pub payoff_ratio: f64, +} + +impl TradeStatistics { + /// Calculate statistics from a list of trades. + pub fn from_trades(trades: &[Trade]) -> Self { + let mut stats = Self::default(); + + if trades.is_empty() { + return stats; + } + + stats.total_trades = trades.len(); + + // Categorize trades + for trade in trades { + if trade.pnl > 0.0 { + stats.winning_trades += 1; + stats.total_profit += trade.pnl; + if trade.pnl > stats.largest_win { + stats.largest_win = trade.pnl; + } + } else if trade.pnl < 0.0 { + stats.losing_trades += 1; + stats.total_loss += trade.pnl.abs(); + if trade.pnl.abs() > stats.largest_loss { + stats.largest_loss = trade.pnl.abs(); + } + } else { + stats.breakeven_trades += 1; + } + } + + // Calculate ratios + stats.net_profit = stats.total_profit - stats.total_loss; + + if stats.total_trades > 0 { + stats.win_rate = stats.winning_trades as f64 / stats.total_trades as f64 * 100.0; + } + + if stats.winning_trades > 0 { + stats.avg_win = stats.total_profit / stats.winning_trades as f64; + } + + if stats.losing_trades > 0 { + stats.avg_loss = stats.total_loss / stats.losing_trades as f64; + } + + if stats.total_loss > 0.0 { + stats.profit_factor = stats.total_profit / stats.total_loss; + } else if stats.total_profit > 0.0 { + stats.profit_factor = f64::INFINITY; + } + + if stats.avg_loss > 0.0 { + stats.payoff_ratio = stats.avg_win / stats.avg_loss; + } + + // Expectancy + if stats.total_trades > 0 { + stats.expectancy = stats.net_profit / stats.total_trades as f64; + } + + // Average return percentage + if stats.total_trades > 0 { + stats.avg_return_pct = + trades.iter().map(|t| t.return_pct).sum::() / stats.total_trades as f64; + } + + // Average holding period + if stats.total_trades > 0 { + stats.avg_holding_period = + trades.iter().map(|t| t.holding_period() as f64).sum::() + / stats.total_trades as f64; + } + + // Consecutive wins/losses + let (max_wins, max_losses) = calculate_consecutive(trades); + stats.max_consecutive_wins = max_wins; + stats.max_consecutive_losses = max_losses; + + // Recovery factor + if stats.largest_loss > 0.0 { + stats.recovery_factor = stats.net_profit / stats.largest_loss; + } + + // Win/loss ratio + if stats.losing_trades > 0 { + stats.avg_win_loss_ratio = stats.winning_trades as f64 / stats.losing_trades as f64; + } + + stats + } + + /// Get summary as formatted string. + pub fn summary(&self) -> String { + format!( + "Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2} | Net: {:.2}", + self.total_trades, self.win_rate, self.profit_factor, self.net_profit + ) + } + + /// Check if strategy is profitable. + pub fn is_profitable(&self) -> bool { + self.net_profit > 0.0 + } + + /// Get edge (expected value as percentage of average trade). + pub fn edge(&self) -> f64 { + if self.total_trades == 0 { + return 0.0; + } + let avg_trade = self.net_profit / self.total_trades as f64; + let avg_cost = (self.total_profit + self.total_loss) / self.total_trades as f64; + if avg_cost > 0.0 { + avg_trade / avg_cost * 100.0 + } else { + 0.0 + } + } +} + +/// Calculate maximum consecutive wins and losses. +fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) { + let mut max_wins = 0; + let mut max_losses = 0; + let mut current_wins = 0; + let mut current_losses = 0; + + for trade in trades { + if trade.pnl > 0.0 { + current_wins += 1; + current_losses = 0; + max_wins = max_wins.max(current_wins); + } else if trade.pnl < 0.0 { + current_losses += 1; + current_wins = 0; + max_losses = max_losses.max(current_losses); + } + } + + (max_wins, max_losses) +} + +/// Monthly returns breakdown. +#[derive(Debug, Clone, Default)] +pub struct MonthlyReturns { + /// Year. + pub year: i32, + /// Month (1-12). + pub month: u8, + /// Return percentage. + pub return_pct: f64, + /// Number of trades. + pub trade_count: usize, +} + +/// Calculate trade statistics by exit reason. +pub fn stats_by_exit_reason( + trades: &[Trade], +) -> std::collections::HashMap { + use crate::core::types::ExitReason; + use std::collections::HashMap; + + let mut grouped: HashMap> = HashMap::new(); + + for trade in trades { + grouped.entry(trade.exit_reason).or_default().push(trade); + } + + grouped + .into_iter() + .map(|(reason, trade_refs)| { + let owned_trades: Vec = trade_refs.into_iter().cloned().collect(); + (reason, TradeStatistics::from_trades(&owned_trades)) + }) + .collect() +} + +/// Calculate statistics for long vs short trades. +pub fn stats_by_direction(trades: &[Trade]) -> (TradeStatistics, TradeStatistics) { + use crate::core::types::Direction; + + let long_trades: Vec = + trades.iter().filter(|t| t.direction == Direction::Long).cloned().collect(); + + let short_trades: Vec = + trades.iter().filter(|t| t.direction == Direction::Short).cloned().collect(); + + (TradeStatistics::from_trades(&long_trades), TradeStatistics::from_trades(&short_trades)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::{Direction, ExitReason}; + + fn sample_trades() -> Vec { + vec![ + Trade { + id: 1, + symbol: "TEST".to_string(), + entry_idx: 0, + exit_idx: 5, + entry_price: 100.0, + exit_price: 110.0, + size: 10.0, + direction: Direction::Long, + pnl: 100.0, // Win + return_pct: 10.0, + entry_time: 0, + exit_time: 5, + fees: 0.0, + exit_reason: ExitReason::Signal, + }, + Trade { + id: 2, + symbol: "TEST".to_string(), + entry_idx: 10, + exit_idx: 15, + entry_price: 100.0, + exit_price: 95.0, + size: 10.0, + direction: Direction::Long, + pnl: -50.0, // Loss + return_pct: -5.0, + entry_time: 10, + exit_time: 15, + fees: 0.0, + exit_reason: ExitReason::StopLoss, + }, + Trade { + id: 3, + symbol: "TEST".to_string(), + entry_idx: 20, + exit_idx: 25, + entry_price: 100.0, + exit_price: 108.0, + size: 10.0, + direction: Direction::Long, + pnl: 80.0, // Win + return_pct: 8.0, + entry_time: 20, + exit_time: 25, + fees: 0.0, + exit_reason: ExitReason::TakeProfit, + }, + ] + } + + #[test] + fn test_basic_stats() { + let trades = sample_trades(); + let stats = TradeStatistics::from_trades(&trades); + + assert_eq!(stats.total_trades, 3); + assert_eq!(stats.winning_trades, 2); + assert_eq!(stats.losing_trades, 1); + assert!((stats.win_rate - 66.67).abs() < 0.1); + } + + #[test] + fn test_profit_calculations() { + let trades = sample_trades(); + let stats = TradeStatistics::from_trades(&trades); + + assert!((stats.total_profit - 180.0).abs() < 1e-10); + assert!((stats.total_loss - 50.0).abs() < 1e-10); + assert!((stats.net_profit - 130.0).abs() < 1e-10); + assert!((stats.profit_factor - 3.6).abs() < 0.1); + } + + #[test] + fn test_consecutive() { + let trades = sample_trades(); + let (max_wins, max_losses) = calculate_consecutive(&trades); + + // W, L, W -> max consecutive wins = 1, max consecutive losses = 1 + assert_eq!(max_wins, 1); + assert_eq!(max_losses, 1); + } + + #[test] + fn test_stats_by_exit_reason() { + let trades = sample_trades(); + let by_reason = stats_by_exit_reason(&trades); + + // Should have 3 different exit reasons + assert!(by_reason.contains_key(&ExitReason::Signal)); + assert!(by_reason.contains_key(&ExitReason::StopLoss)); + assert!(by_reason.contains_key(&ExitReason::TakeProfit)); + } + + #[test] + fn test_empty_trades() { + let stats = TradeStatistics::from_trades(&[]); + + assert_eq!(stats.total_trades, 0); + assert!((stats.win_rate - 0.0).abs() < 1e-10); + assert!((stats.profit_factor - 0.0).abs() < 1e-10); + } +} diff --git a/src/portfolio/allocation.rs b/src/portfolio/allocation.rs new file mode 100644 index 0000000..13bf1e8 --- /dev/null +++ b/src/portfolio/allocation.rs @@ -0,0 +1,340 @@ +//! Capital allocation strategies for portfolio management. + +/// Allocation strategy for distributing capital across instruments. +#[derive(Debug, Clone)] +pub enum AllocationStrategy { + /// Equal weight across all instruments. + EqualWeight, + /// Fixed weight for each instrument. + FixedWeight(Vec), + /// Volatility-based weighting (inverse volatility). + InverseVolatility, + /// Risk parity (equal risk contribution). + RiskParity, + /// Maximum weight per instrument. + MaxWeight(f64), + /// Custom weights. + Custom(Vec<(String, f64)>), +} + +impl Default for AllocationStrategy { + fn default() -> Self { + AllocationStrategy::EqualWeight + } +} + +/// Capital allocator for managing position sizing and capital distribution. +#[derive(Debug, Clone)] +pub struct CapitalAllocator { + /// Total capital. + pub total_capital: f64, + /// Available capital (not in positions). + pub available_capital: f64, + /// Allocation strategy. + pub strategy: AllocationStrategy, + /// Maximum position size as fraction of capital. + pub max_position_size: f64, + /// Minimum position size (absolute). + pub min_position_size: f64, + /// Reserve capital fraction (never allocate). + pub reserve_fraction: f64, +} + +impl CapitalAllocator { + /// Create a new capital allocator. + pub fn new(total_capital: f64) -> Self { + Self { + total_capital, + available_capital: total_capital, + strategy: AllocationStrategy::EqualWeight, + max_position_size: 1.0, + min_position_size: 0.0, + reserve_fraction: 0.0, + } + } + + /// Set allocation strategy. + pub fn with_strategy(mut self, strategy: AllocationStrategy) -> Self { + self.strategy = strategy; + self + } + + /// Set maximum position size. + pub fn with_max_position(mut self, max_fraction: f64) -> Self { + self.max_position_size = max_fraction.clamp(0.0, 1.0); + self + } + + /// Set reserve fraction. + pub fn with_reserve(mut self, reserve: f64) -> Self { + self.reserve_fraction = reserve.clamp(0.0, 1.0); + self + } + + /// Calculate position size for a single instrument. + /// + /// # Arguments + /// * `price` - Entry price + /// * `num_instruments` - Total number of instruments in portfolio + /// * `instrument_weight` - Optional custom weight for this instrument + /// + /// # Returns + /// Position size in shares/contracts + pub fn calculate_position_size( + &self, + price: f64, + num_instruments: usize, + instrument_weight: Option, + ) -> f64 { + if price <= 0.0 || num_instruments == 0 { + return 0.0; + } + + // Calculate allocatable capital + let allocatable = self.available_capital * (1.0 - self.reserve_fraction); + + // Calculate weight + let weight = match &self.strategy { + AllocationStrategy::EqualWeight => 1.0 / num_instruments as f64, + AllocationStrategy::FixedWeight(weights) => { + if weights.is_empty() { + 1.0 / num_instruments as f64 + } else { + weights[0].min(self.max_position_size) + } + } + AllocationStrategy::MaxWeight(max) => (*max).min(1.0 / num_instruments as f64), + _ => instrument_weight.unwrap_or(1.0 / num_instruments as f64), + }; + + // Calculate allocation + let allocation = allocatable * weight.min(self.max_position_size); + + // Convert to shares + let shares = allocation / price; + + // Apply minimum size constraint + if shares * price < self.min_position_size { + return 0.0; + } + + shares + } + + /// Calculate position sizes for multiple instruments. + /// + /// # Arguments + /// * `prices` - Entry prices for each instrument + /// * `weights` - Optional weights for each instrument + /// + /// # Returns + /// Position sizes for each instrument + pub fn calculate_portfolio_sizes(&self, prices: &[f64], weights: Option<&[f64]>) -> Vec { + let n = prices.len(); + if n == 0 { + return vec![]; + } + + let allocatable = self.available_capital * (1.0 - self.reserve_fraction); + + // Get weights + let instrument_weights: Vec = match &self.strategy { + AllocationStrategy::EqualWeight => vec![1.0 / n as f64; n], + AllocationStrategy::FixedWeight(w) => { + if w.len() == n { + w.clone() + } else { + vec![1.0 / n as f64; n] + } + } + AllocationStrategy::MaxWeight(max) => { + let equal = 1.0 / n as f64; + vec![equal.min(*max); n] + } + _ => weights.map(|w| w.to_vec()).unwrap_or_else(|| vec![1.0 / n as f64; n]), + }; + + // Normalize weights + let total_weight: f64 = instrument_weights.iter().sum(); + let normalized_weights: Vec = if total_weight > 0.0 { + instrument_weights.iter().map(|w| w / total_weight).collect() + } else { + vec![1.0 / n as f64; n] + }; + + // Calculate sizes + prices + .iter() + .zip(normalized_weights.iter()) + .map(|(&price, &weight)| { + if price <= 0.0 { + return 0.0; + } + let allocation = allocatable * weight.min(self.max_position_size); + let shares = allocation / price; + if shares * price < self.min_position_size { + 0.0 + } else { + shares + } + }) + .collect() + } + + /// Calculate volatility-adjusted position size. + /// + /// # Arguments + /// * `price` - Entry price + /// * `volatility` - Instrument volatility (e.g., ATR) + /// * `risk_per_trade` - Risk per trade as fraction of capital + /// + /// # Returns + /// Position size + pub fn calculate_volatility_sized( + &self, + price: f64, + volatility: f64, + risk_per_trade: f64, + ) -> f64 { + if price <= 0.0 || volatility <= 0.0 { + return 0.0; + } + + let risk_amount = self.available_capital * risk_per_trade; + let size = risk_amount / volatility; + + // Apply maximum constraint + let max_allocation = self.available_capital * self.max_position_size; + let max_shares = max_allocation / price; + + size.min(max_shares) + } + + /// Allocate capital to a position. + /// + /// # Arguments + /// * `amount` - Amount to allocate + /// + /// # Returns + /// True if allocation succeeded + pub fn allocate(&mut self, amount: f64) -> bool { + if amount > self.available_capital { + return false; + } + self.available_capital -= amount; + true + } + + /// Release capital from a closed position. + /// + /// # Arguments + /// * `amount` - Amount to release (including P&L) + pub fn release(&mut self, amount: f64) { + self.available_capital += amount; + } + + /// Update total capital (e.g., after deposit/withdrawal or daily mark-to-market). + pub fn update_capital(&mut self, new_capital: f64) { + let diff = new_capital - self.total_capital; + self.total_capital = new_capital; + self.available_capital += diff; + } + + /// Get current utilization rate. + pub fn utilization(&self) -> f64 { + if self.total_capital <= 0.0 { + return 0.0; + } + 1.0 - (self.available_capital / self.total_capital) + } + + /// Reset allocator to initial state. + pub fn reset(&mut self) { + self.available_capital = self.total_capital; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_equal_weight() { + let allocator = CapitalAllocator::new(100_000.0); + + // 4 instruments, equal weight = 25% each + let size = allocator.calculate_position_size(100.0, 4, None); + + // Expected: 100000 * 0.25 / 100 = 250 shares + assert!((size - 250.0).abs() < 1e-10); + } + + #[test] + fn test_max_position() { + let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.1); + + // Even with 1 instrument, max is 10% + let size = allocator.calculate_position_size(100.0, 1, None); + + // Expected: 100000 * 0.1 / 100 = 100 shares + assert!((size - 100.0).abs() < 1e-10); + } + + #[test] + fn test_portfolio_sizes() { + let allocator = CapitalAllocator::new(100_000.0); + + let prices = vec![100.0, 50.0, 200.0]; + let sizes = allocator.calculate_portfolio_sizes(&prices, None); + + assert_eq!(sizes.len(), 3); + + // Equal weight, each gets 1/3 of capital + // Instrument 1: 33333 / 100 = 333.33 + // Instrument 2: 33333 / 50 = 666.66 + // Instrument 3: 33333 / 200 = 166.66 + assert!((sizes[0] - 333.33).abs() < 1.0); + assert!((sizes[1] - 666.66).abs() < 1.0); + assert!((sizes[2] - 166.66).abs() < 1.0); + } + + #[test] + fn test_allocate_release() { + let mut allocator = CapitalAllocator::new(100_000.0); + + // Allocate 30000 + assert!(allocator.allocate(30_000.0)); + assert!((allocator.available_capital - 70_000.0).abs() < 1e-10); + + // Try to allocate more than available + assert!(!allocator.allocate(80_000.0)); + + // Release with profit + allocator.release(35_000.0); + assert!((allocator.available_capital - 105_000.0).abs() < 1e-10); + } + + #[test] + fn test_utilization() { + let mut allocator = CapitalAllocator::new(100_000.0); + + assert!((allocator.utilization() - 0.0).abs() < 1e-10); + + allocator.allocate(50_000.0); + assert!((allocator.utilization() - 0.5).abs() < 1e-10); + } + + #[test] + fn test_volatility_sizing() { + let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.2); + + // Risk 1% per trade with ATR of 2 + let size = allocator.calculate_volatility_sized(100.0, 2.0, 0.01); + + // Risk amount: 100000 * 0.01 = 1000 + // Size: 1000 / 2 = 500 shares + // Max: 100000 * 0.2 / 100 = 200 shares + // Should be capped at max + assert!((size - 200.0).abs() < 1e-10); + } +} diff --git a/src/portfolio/engine.rs b/src/portfolio/engine.rs new file mode 100644 index 0000000..39e2228 --- /dev/null +++ b/src/portfolio/engine.rs @@ -0,0 +1,902 @@ +//! Event-driven portfolio simulation engine. + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason, + InstrumentConfig, OhlcvData, Price, StopConfig, TargetConfig, Trade, +}; +use crate::execution::{FeeModel, FillPrice, SlippageModel}; +use crate::indicators::volatility::atr; +use crate::metrics::streaming::StreamingMetrics; +use crate::portfolio::position::PositionManager; +use crate::signals::processor::SignalProcessor; + +/// Portfolio simulation engine. +/// +/// Single-pass O(n) algorithm for simulating portfolio performance. +#[derive(Debug)] +pub struct PortfolioEngine { + /// Configuration. + pub config: BacktestConfig, + /// Fee model. + pub fee_model: FeeModel, + /// Slippage model. + pub slippage_model: SlippageModel, + /// Fill price model. + pub fill_price: FillPrice, + /// Signal processor. + pub signal_processor: SignalProcessor, +} + +impl Default for PortfolioEngine { + fn default() -> Self { + Self::new(BacktestConfig::default()) + } +} + +impl PortfolioEngine { + /// Create a new portfolio engine with the given configuration. + pub fn new(config: BacktestConfig) -> Self { + let fee_model = FeeModel::percentage(config.fees); + let fill_price = if config.upon_bar_close { FillPrice::Close } else { FillPrice::Open }; + + Self { + config, + fee_model, + slippage_model: SlippageModel::None, + fill_price, + signal_processor: SignalProcessor::new(), + } + } + + /// Set fee model. + pub fn with_fee_model(mut self, fee_model: FeeModel) -> Self { + self.fee_model = fee_model; + self + } + + /// Set slippage model. + pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self { + self.slippage_model = slippage_model; + self + } + + /// Run backtest on single instrument. + /// + /// # Arguments + /// * `ohlcv` - OHLCV data + /// * `signals` - Compiled trading signals + /// + /// # Returns + /// Backtest result + pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult { + self.run_single_with_instrument_config(ohlcv, signals, None) + } + + /// Run backtest on single instrument with optional per-instrument configuration. + /// + /// # Arguments + /// * `ohlcv` - OHLCV data + /// * `signals` - Compiled trading signals + /// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides) + /// + /// # Returns + /// Backtest result + pub fn run_single_with_instrument_config( + &self, + ohlcv: &OhlcvData, + signals: &CompiledSignals, + inst_config: Option<&InstrumentConfig>, + ) -> BacktestResult { + let n = ohlcv.len(); + assert_eq!(n, signals.len(), "OHLCV and signals must have same length"); + + // Clean signals + let (entries, exits) = + self.signal_processor.clean_signals(&signals.entries, &signals.exits); + + // Initialize state + let mut position = PositionManager::new(signals.symbol.clone()); + let mut cash = self.config.initial_capital; + let mut equity_curve = vec![cash; n]; + let mut drawdown_curve = vec![0.0; n]; + let mut returns = vec![0.0; n]; + let mut trades: Vec = Vec::new(); + let mut streaming = StreamingMetrics::new(); + let mut peak_equity = cash; + + // Determine effective stop/target configs (per-instrument overrides take precedence) + let effective_stop = + inst_config.and_then(|ic| ic.stop.as_ref()).unwrap_or(&self.config.stop); + let effective_target = + inst_config.and_then(|ic| ic.target.as_ref()).unwrap_or(&self.config.target); + + // Pre-calculate ATR for ATR-based stops + let atr_values = if matches!(effective_stop, StopConfig::Atr { .. }) + || matches!(effective_target, TargetConfig::Atr { .. }) + { + let period = match effective_stop { + StopConfig::Atr { period, .. } => *period, + _ => match effective_target { + TargetConfig::Atr { period, .. } => *period, + _ => 14, + }, + }; + atr(&ohlcv.high, &ohlcv.low, &ohlcv.close, period).unwrap_or_else(|_| vec![0.0; n]) + } else { + vec![0.0; n] + }; + + // Main simulation loop + for i in 0..n { + let close = ohlcv.close[i]; + let high = ohlcv.high[i]; + let low = ohlcv.low[i]; + let timestamp = ohlcv.timestamps[i]; + + // Update position price tracking + position.update_price(high, low); + + // Check for exits first (stops and signals) + if position.is_in_position() { + let mut exit_reason: Option = None; + let mut exit_price = close; + + // Check stop-loss + if position.is_stop_hit(low, high) { + exit_reason = Some(ExitReason::StopLoss); + exit_price = position.position.stop_price.unwrap(); + + // Adjust for gap through stop + match position.position.direction { + Direction::Long => { + if ohlcv.open[i] < exit_price { + exit_price = ohlcv.open[i]; + } + } + Direction::Short => { + if ohlcv.open[i] > exit_price { + exit_price = ohlcv.open[i]; + } + } + } + } + + // Check take-profit + if exit_reason.is_none() && position.is_target_hit(low, high) { + exit_reason = Some(ExitReason::TakeProfit); + exit_price = position.position.target_price.unwrap(); + } + + // Check exit signal + if exit_reason.is_none() && exits[i] { + exit_reason = Some(ExitReason::Signal); + exit_price = self.get_fill_price(ohlcv, i, signals.direction, false); + } + + // Execute exit + if let Some(reason) = exit_reason { + // Apply slippage + exit_price = self.slippage_model.apply( + exit_price, + position.position.direction, + false, + Some(ohlcv.volume[i]), + ); + + // Calculate fees + let fees = self.fee_model.calculate( + exit_price, + position.position.size, + position.position.direction, + ); + + // Close position + if let Some(trade) = position.close_position( + i, + timestamp, + exit_price, + ohlcv.timestamps[position.position.entry_idx], + reason, + fees, + ) { + // Update cash + let exit_value = exit_price * trade.size; + cash += exit_value - fees; + + // Track return for this trade + streaming.update(trade.return_pct / 100.0); + + trades.push(trade); + } + } + + // Update trailing stop if position still open + if position.is_in_position() { + if let StopConfig::Trailing { percent } = effective_stop { + position.update_trailing_stop(*percent); + } + } + } + + // Check for entries + if !position.is_in_position() && entries[i] { + let entry_price = self.get_fill_price(ohlcv, i, signals.direction, true); + + // Apply slippage + let adjusted_price = self.slippage_model.apply( + entry_price, + signals.direction, + true, + Some(ohlcv.volume[i]), + ); + + // Calculate position size + // Use per-instrument capital if set, capped at available cash + let available = inst_config + .and_then(|ic| ic.alloted_capital) + .map(|cap| cap.min(cash)) + .unwrap_or(cash); + + // Position sizing: size = cash / (price * (1 + fees)) + // Ensures position value plus entry fee equals available cash + let fee_rate = self.config.fees; + let raw_size = if let Some(ref sizes) = signals.position_sizes { + sizes[i] * available / (adjusted_price * (1.0 + fee_rate)) + } else { + available / (adjusted_price * (1.0 + fee_rate)) + }; + + // Round to lot_size + let size = inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size); + + if size > 0.0 { + // Calculate entry fees + let entry_fees = + self.fee_model.calculate(adjusted_price, size, signals.direction); + + // Calculate stop and target prices + let (stop_price, target_price) = self.calculate_stop_target_with_config( + adjusted_price, + signals.direction, + &atr_values, + i, + effective_stop, + effective_target, + ); + + // Open position (passing entry_fees for trade PnL tracking) + position.open_position( + i, + timestamp, + adjusted_price, + size, + signals.direction, + stop_price, + target_price, + entry_fees, + ); + + // Deduct cost + cash -= adjusted_price * size + entry_fees; + } + } + + // Calculate equity + let position_value = + if position.is_in_position() { close * position.position.size } else { 0.0 }; + let equity = cash + position_value; + equity_curve[i] = equity; + + // Calculate drawdown + if equity > peak_equity { + peak_equity = equity; + } + drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0; + + // Calculate return + if i > 0 { + returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1]; + } + } + + // Mark any open position at end of data — marked-to-market, no exit fees + if position.is_in_position() { + let last_idx = n - 1; + let exit_price = ohlcv.close[last_idx]; + // No exit fees for EndOfData: position is marked-to-market but not actually closed + let exit_fees = 0.0; + + if let Some(trade) = position.close_position( + last_idx, + ohlcv.timestamps[last_idx], + exit_price, + ohlcv.timestamps[position.position.entry_idx], + ExitReason::EndOfData, + exit_fees, + ) { + streaming.update(trade.return_pct / 100.0); + trades.push(trade); + } + } + + // Calculate final metrics + let metrics = + self.calculate_metrics(&equity_curve, &drawdown_curve, &returns, &trades, &streaming); + + BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns) + } + + /// Get fill price based on model. + fn get_fill_price( + &self, + ohlcv: &OhlcvData, + idx: usize, + direction: Direction, + is_entry: bool, + ) -> Price { + self.fill_price.get_price_from_arrays( + ohlcv.open[idx], + ohlcv.high[idx], + ohlcv.low[idx], + ohlcv.close[idx], + direction, + is_entry, + ) + } + + /// Calculate stop and target prices using the global config. + #[allow(dead_code)] + fn calculate_stop_target( + &self, + entry_price: Price, + direction: Direction, + atr_values: &[f64], + idx: usize, + ) -> (Option, Option) { + self.calculate_stop_target_with_config( + entry_price, + direction, + atr_values, + idx, + &self.config.stop, + &self.config.target, + ) + } + + /// Calculate stop and target prices with explicit stop/target configs. + fn calculate_stop_target_with_config( + &self, + entry_price: Price, + direction: Direction, + atr_values: &[f64], + idx: usize, + stop_config: &StopConfig, + target_config: &TargetConfig, + ) -> (Option, Option) { + let multiplier = direction.multiplier(); + + // Calculate stop price + let stop_price = match stop_config { + StopConfig::None => None, + StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)), + StopConfig::Atr { multiplier: m, .. } => { + let atr = atr_values.get(idx).copied().unwrap_or(0.0); + if atr > 0.0 { + Some(entry_price - multiplier * m * atr) + } else { + None + } + } + StopConfig::Trailing { percent } => Some(entry_price * (1.0 - multiplier * percent)), + }; + + // Calculate target price + let target_price = match target_config { + TargetConfig::None => None, + TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)), + TargetConfig::Atr { multiplier: m, .. } => { + let atr = atr_values.get(idx).copied().unwrap_or(0.0); + if atr > 0.0 { + Some(entry_price + multiplier * m * atr) + } else { + None + } + } + TargetConfig::RiskReward { ratio } => { + if let Some(stop) = stop_price { + let risk = (entry_price - stop).abs(); + Some(entry_price + multiplier * risk * ratio) + } else { + None + } + } + }; + + (stop_price, target_price) + } + + /// Calculate backtest metrics. + fn calculate_metrics( + &self, + equity_curve: &[f64], + drawdown_curve: &[f64], + returns: &[f64], + trades: &[Trade], + _streaming: &StreamingMetrics, + ) -> BacktestMetrics { + let start_value = self.config.initial_capital; + let end_value = *equity_curve.last().unwrap_or(&start_value); + + let total_return_pct = (end_value - start_value) / start_value * 100.0; + let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b)); + + // Calculate max drawdown duration + let max_drawdown_duration = self.calculate_max_drawdown_duration(drawdown_curve); + + // Trade statistics + let total_trades = trades.len(); + + // Separate closed vs open trades (EndOfData means still open) + let total_open_trades = + trades.iter().filter(|t| matches!(t.exit_reason, ExitReason::EndOfData)).count(); + let total_closed_trades = total_trades.saturating_sub(total_open_trades); + + // Open trade PnL + let open_trade_pnl: f64 = trades + .iter() + .filter(|t| matches!(t.exit_reason, ExitReason::EndOfData)) + .map(|t| t.pnl) + .sum(); + + // Only count closed trades for win/loss statistics + let closed_trades: Vec<_> = + trades.iter().filter(|t| !matches!(t.exit_reason, ExitReason::EndOfData)).collect(); + + let winning_trades = closed_trades.iter().filter(|t| t.pnl > 0.0).count(); + let losing_trades = closed_trades.iter().filter(|t| t.pnl < 0.0).count(); + + let win_rate_pct = if total_closed_trades > 0 { + winning_trades as f64 / total_closed_trades as f64 * 100.0 + } else { + 0.0 + }; + + // Total fees paid + let total_fees_paid: f64 = trades.iter().map(|t| t.fees).sum(); + + // Best and worst trade + let best_trade_pct = + trades.iter().map(|t| t.return_pct).fold(f64::NEG_INFINITY, |a, b| a.max(b)); + let best_trade_pct = if best_trade_pct.is_infinite() { 0.0 } else { best_trade_pct }; + + let worst_trade_pct = + trades.iter().map(|t| t.return_pct).fold(f64::INFINITY, |a, b| a.min(b)); + let worst_trade_pct = if worst_trade_pct.is_infinite() { 0.0 } else { worst_trade_pct }; + + // Profit factor (based on closed trades) + let gross_profit: f64 = closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum(); + let gross_loss: f64 = + closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum(); + let profit_factor = if gross_loss > 0.0 { + gross_profit / gross_loss + } else if gross_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Expectancy = average trade PnL + let expectancy = if total_closed_trades > 0 { + closed_trades.iter().map(|t| t.pnl).sum::() / total_closed_trades as f64 + } else { + 0.0 + }; + + // SQN = (Expectancy / StdDev of trade PnL) * sqrt(total trades) + let sqn = if total_closed_trades > 1 { + let trade_pnls: Vec = closed_trades.iter().map(|t| t.pnl).collect(); + let mean = expectancy; + let variance = trade_pnls.iter().map(|p| (p - mean).powi(2)).sum::() + / (total_closed_trades - 1) as f64; + let std_dev = variance.sqrt(); + if std_dev > 0.0 { + (mean / std_dev) * (total_closed_trades as f64).sqrt() + } else { + 0.0 + } + } else { + 0.0 + }; + + // Average returns + let avg_trade_return_pct = if total_trades > 0 { + trades.iter().map(|t| t.return_pct).sum::() / total_trades as f64 + } else { + 0.0 + }; + + let avg_win_pct = if winning_trades > 0 { + closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.return_pct).sum::() + / winning_trades as f64 + } else { + 0.0 + }; + + let avg_loss_pct = if losing_trades > 0 { + closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.return_pct).sum::() + / losing_trades as f64 + } else { + 0.0 + }; + + // Average winning/losing trade duration + let avg_winning_duration = if winning_trades > 0 { + closed_trades + .iter() + .filter(|t| t.pnl > 0.0) + .map(|t| t.holding_period() as f64) + .sum::() + / winning_trades as f64 + } else { + 0.0 + }; + + let avg_losing_duration = if losing_trades > 0 { + closed_trades + .iter() + .filter(|t| t.pnl < 0.0) + .map(|t| t.holding_period() as f64) + .sum::() + / losing_trades as f64 + } else { + 0.0 + }; + + // Consecutive wins/losses + let (max_consecutive_wins, max_consecutive_losses) = self.calculate_consecutive(trades); + + // Holding period + let avg_holding_period = if total_trades > 0 { + trades.iter().map(|t| t.holding_period() as f64).sum::() / total_trades as f64 + } else { + 0.0 + }; + + // Exposure (time in market) + let bars_in_position: usize = trades.iter().map(|t| t.holding_period()).sum(); + let exposure_pct = if !equity_curve.is_empty() { + bars_in_position as f64 / equity_curve.len() as f64 * 100.0 + } else { + 0.0 + }; + + // Risk-adjusted metrics (calculated from daily portfolio returns, not trade returns) + let (sharpe_ratio, sortino_ratio, omega_ratio) = self.calculate_risk_metrics(returns); + + // Calmar ratio: CAGR / max drawdown + let num_periods = equity_curve.len().max(1) as f64; + let years = num_periods / 365.25; // Convert to years using 365.25 days + let total_return_frac = total_return_pct / 100.0; + // CAGR = (end/start)^(1/years) - 1 = (1 + total_return)^(1/years) - 1 + let cagr = + if years > 0.0 { (1.0 + total_return_frac).powf(1.0 / years) - 1.0 } else { 0.0 }; + let calmar_ratio = if max_drawdown_pct > 0.0 { + cagr / (max_drawdown_pct / 100.0) // Both as fractions + } else if total_return_pct > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Payoff ratio: average win / average loss (absolute value) + let payoff_ratio = if avg_loss_pct.abs() > 0.0 { + avg_win_pct / avg_loss_pct.abs() + } else if avg_win_pct > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Recovery factor: net profit / max drawdown (absolute value) + let net_profit = end_value - start_value; + let recovery_factor = if max_drawdown_pct > 0.0 && start_value > 0.0 { + let max_dd_absolute = max_drawdown_pct / 100.0 * start_value; + if max_dd_absolute > 0.0 { + net_profit / max_dd_absolute + } else { + 0.0 + } + } else if net_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + BacktestMetrics { + total_return_pct, + sharpe_ratio, + sortino_ratio, + calmar_ratio, + omega_ratio, + max_drawdown_pct, + max_drawdown_duration, + win_rate_pct, + profit_factor, + expectancy, + sqn, + total_trades, + total_closed_trades, + total_open_trades, + open_trade_pnl, + winning_trades, + losing_trades, + start_value, + end_value, + total_fees_paid, + best_trade_pct, + worst_trade_pct, + avg_trade_return_pct, + avg_win_pct, + avg_loss_pct, + avg_winning_duration, + avg_losing_duration, + max_consecutive_wins, + max_consecutive_losses, + avg_holding_period, + exposure_pct, + payoff_ratio, + recovery_factor, + } + } + + /// Calculate max drawdown duration from drawdown curve. + fn calculate_max_drawdown_duration(&self, drawdown_curve: &[f64]) -> usize { + let mut max_duration = 0; + let mut current_duration = 0; + + for &dd in drawdown_curve { + if dd > 0.0 { + current_duration += 1; + max_duration = max_duration.max(current_duration); + } else { + current_duration = 0; + } + } + + max_duration + } + + /// Calculate max consecutive wins and losses. + fn calculate_consecutive(&self, trades: &[Trade]) -> (usize, usize) { + let mut max_wins = 0; + let mut max_losses = 0; + let mut current_wins = 0; + let mut current_losses = 0; + + for trade in trades { + if trade.pnl > 0.0 { + current_wins += 1; + current_losses = 0; + max_wins = max_wins.max(current_wins); + } else if trade.pnl < 0.0 { + current_losses += 1; + current_wins = 0; + max_losses = max_losses.max(current_losses); + } + } + + (max_wins, max_losses) + } + + /// Calculate risk-adjusted metrics from daily portfolio returns. + /// Returns (sharpe_ratio, sortino_ratio, omega_ratio). + /// Uses 365 calendar days for annualization. + fn calculate_risk_metrics(&self, returns: &[f64]) -> (f64, f64, f64) { + if returns.len() < 2 { + return (0.0, 0.0, 1.0); + } + + // 365 calendar days for annualization + let periods_per_year: f64 = 365.0; + let _n = returns.len() as f64; + + // Filter out NaN values + let valid_returns: Vec = returns.iter().filter(|r| !r.is_nan()).copied().collect(); + + if valid_returns.len() < 2 { + return (0.0, 0.0, 1.0); + } + + let n_valid = valid_returns.len() as f64; + + // Calculate mean return + let mean = valid_returns.iter().sum::() / n_valid; + + // Calculate standard deviation + let variance = + valid_returns.iter().map(|r| (r - mean).powi(2)).sum::() / (n_valid - 1.0); + let std_dev = variance.sqrt(); + + // Sharpe Ratio = (mean * periods_per_year) / (std_dev * sqrt(periods_per_year)) + // Simplified: Sharpe = mean / std_dev * sqrt(periods_per_year) + let sharpe_ratio = + if std_dev > 0.0 { (mean / std_dev) * periods_per_year.sqrt() } else { 0.0 }; + + // Sortino Ratio - uses downside deviation (only negative returns) + let downside_returns: Vec = + valid_returns.iter().filter(|&&r| r < 0.0).copied().collect(); + + let downside_variance = if !downside_returns.is_empty() { + downside_returns.iter().map(|r| r.powi(2)).sum::() / n_valid // Divide by total count, not downside count + } else { + 0.0 + }; + let downside_std = downside_variance.sqrt(); + + let sortino_ratio = if downside_std > 0.0 { + (mean / downside_std) * periods_per_year.sqrt() + } else if mean > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Omega Ratio = sum of returns above threshold / |sum of returns below threshold| + // With threshold = 0 + let sum_positive: f64 = valid_returns.iter().filter(|&&r| r > 0.0).sum(); + let sum_negative: f64 = valid_returns.iter().filter(|&&r| r < 0.0).map(|r| r.abs()).sum(); + + let omega_ratio = if sum_negative > 0.0 { + sum_positive / sum_negative + } else if sum_positive > 0.0 { + f64::INFINITY + } else { + 1.0 + }; + + (sharpe_ratio, sortino_ratio, omega_ratio) + } +} + +/// Compute `BacktestMetrics` from pre-built curves and trade list. +/// +/// Exposed as a standalone function so non-OHLCV strategies (e.g. tick backtest) +/// can produce identical metrics without duplicating the calculation logic. +pub fn compute_backtest_metrics( + equity_curve: &[f64], + drawdown_curve: &[f64], + returns: &[f64], + trades: &[Trade], + initial_capital: f64, +) -> BacktestMetrics { + // Delegate to a throwaway engine instance — avoids duplicating the logic. + let engine = PortfolioEngine::new(BacktestConfig { + initial_capital, + ..Default::default() + }); + engine.calculate_metrics(equity_curve, drawdown_curve, returns, trades, &StreamingMetrics::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_ohlcv() -> OhlcvData { + OhlcvData { + timestamps: (0..20).map(|i| i as i64).collect(), + open: vec![ + 100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0, + 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, + ], + high: vec![ + 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0, + 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0, + ], + low: vec![ + 99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0, + 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, + ], + close: vec![ + 100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5, + 102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5, + ], + volume: vec![1000.0; 20], + } + } + + fn sample_signals() -> CompiledSignals { + CompiledSignals { + symbol: "TEST".to_string(), + entries: vec![ + false, true, false, false, false, false, false, false, false, false, false, true, + false, false, false, false, false, false, false, false, + ], + exits: vec![ + false, false, false, false, false, true, false, false, false, false, false, false, + false, false, false, true, false, false, false, false, + ], + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + } + } + + #[test] + fn test_basic_backtest() { + let config = BacktestConfig { + initial_capital: 100_000.0, + fees: 0.0, + slippage: 0.0, + stop: StopConfig::None, + target: TargetConfig::None, + upon_bar_close: true, + }; + + let engine = PortfolioEngine::new(config); + let ohlcv = sample_ohlcv(); + let signals = sample_signals(); + + let result = engine.run_single(&ohlcv, &signals); + + // Should have 2 trades + assert_eq!(result.trades.len(), 2); + + // First trade: entry at 101.5, exit at 105.0 + let trade1 = &result.trades[0]; + assert!((trade1.entry_price - 101.5).abs() < 1e-10); + assert!((trade1.exit_price - 105.0).abs() < 1e-10); + assert!(trade1.pnl > 0.0); // Profitable + + // Equity curve should have correct length + assert_eq!(result.equity_curve.len(), 20); + } + + #[test] + fn test_with_fees() { + let config = BacktestConfig { + initial_capital: 100_000.0, + fees: 0.001, // 0.1% + slippage: 0.0, + stop: StopConfig::None, + target: TargetConfig::None, + upon_bar_close: true, + }; + + let engine = PortfolioEngine::new(config); + let ohlcv = sample_ohlcv(); + let signals = sample_signals(); + + let result = engine.run_single(&ohlcv, &signals); + + // Trades should have fees deducted + for trade in &result.trades { + assert!(trade.fees > 0.0); + } + } + + #[test] + fn test_with_stop_loss() { + let config = BacktestConfig { + initial_capital: 100_000.0, + fees: 0.0, + slippage: 0.0, + stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop + target: TargetConfig::None, + upon_bar_close: true, + }; + + let engine = PortfolioEngine::new(config); + + // Create data where stop would be hit + let mut ohlcv = sample_ohlcv(); + // Add a big drop after entry + ohlcv.low[3] = 95.0; // Big drop + ohlcv.close[3] = 96.0; + + let signals = sample_signals(); + let result = engine.run_single(&ohlcv, &signals); + + // First trade should exit on stop loss + assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss); + } +} diff --git a/src/portfolio/mod.rs b/src/portfolio/mod.rs new file mode 100644 index 0000000..00890d0 --- /dev/null +++ b/src/portfolio/mod.rs @@ -0,0 +1,11 @@ +//! Portfolio simulation engine for RaptorBT. + +pub mod allocation; +pub mod engine; +pub mod monte_carlo; +pub mod position; + +pub use allocation::{AllocationStrategy, CapitalAllocator}; +pub use engine::PortfolioEngine; +pub use monte_carlo::{simulate_portfolio_forward, MonteCarloConfig, MonteCarloResult}; +pub use position::PositionManager; diff --git a/src/portfolio/monte_carlo.rs b/src/portfolio/monte_carlo.rs new file mode 100644 index 0000000..1ee95c1 --- /dev/null +++ b/src/portfolio/monte_carlo.rs @@ -0,0 +1,361 @@ +//! Monte Carlo forward simulation for portfolio projection. +//! +//! Uses Geometric Brownian Motion (GBM) with Cholesky decomposition +//! for correlated multi-asset simulation. Parallelized via Rayon. + +use rayon::prelude::*; + +/// Configuration for Monte Carlo simulation. +#[derive(Debug, Clone)] +pub struct MonteCarloConfig { + pub n_simulations: usize, + pub horizon_days: usize, + pub seed: u64, +} + +impl Default for MonteCarloConfig { + fn default() -> Self { + Self { n_simulations: 10_000, horizon_days: 252, seed: 42 } + } +} + +/// Result of a Monte Carlo simulation. +#[derive(Debug, Clone)] +pub struct MonteCarloResult { + /// Percentile paths: Vec of (percentile, path_values) + pub percentile_paths: Vec<(f64, Vec)>, + /// Terminal value for each simulation + pub final_values: Vec, + /// Expected annualized return + pub expected_return: f64, + /// Probability of loss (final value < initial value) + pub probability_of_loss: f64, + /// Value at Risk at 95% confidence + pub var_95: f64, + /// Conditional Value at Risk at 95% confidence + pub cvar_95: f64, +} + +/// Cholesky decomposition of a symmetric positive-definite matrix. +/// Returns lower-triangular matrix L such that A = L * L^T. +fn cholesky(matrix: &[Vec]) -> Result>, &'static str> { + let n = matrix.len(); + let mut l = vec![vec![0.0; n]; n]; + + for i in 0..n { + for j in 0..=i { + let mut sum = 0.0; + for k in 0..j { + sum += l[i][k] * l[j][k]; + } + + if i == j { + let diag = matrix[i][i] - sum; + if diag <= 0.0 { + // Matrix is not positive definite; use a small epsilon + l[i][j] = (diag.abs().max(1e-10)).sqrt(); + } else { + l[i][j] = diag.sqrt(); + } + } else { + if l[j][j].abs() < 1e-15 { + l[i][j] = 0.0; + } else { + l[i][j] = (matrix[i][j] - sum) / l[j][j]; + } + } + } + } + + Ok(l) +} + +/// Simple xoshiro256** PRNG for deterministic parallel simulation. +#[derive(Clone)] +struct Xoshiro256 { + s: [u64; 4], +} + +impl Xoshiro256 { + fn new(seed: u64) -> Self { + // SplitMix64 to seed all 4 state words + let mut z = seed; + let mut s = [0u64; 4]; + for item in &mut s { + z = z.wrapping_add(0x9e3779b97f4a7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb); + *item = z ^ (z >> 31); + } + Self { s } + } + + fn jump(&mut self) { + // Jump function: advances state by 2^128 calls + const JUMP: [u64; 4] = + [0x180ec6d33cfd0aba, 0xd5a61266f0c9392c, 0xa9582618e03fc9aa, 0x39abdc4529b1661c]; + let mut s0: u64 = 0; + let mut s1: u64 = 0; + let mut s2: u64 = 0; + let mut s3: u64 = 0; + for j in &JUMP { + for b in 0..64 { + if j & (1u64 << b) != 0 { + s0 ^= self.s[0]; + s1 ^= self.s[1]; + s2 ^= self.s[2]; + s3 ^= self.s[3]; + } + self.next_u64(); + } + } + self.s[0] = s0; + self.s[1] = s1; + self.s[2] = s2; + self.s[3] = s3; + } + + fn next_u64(&mut self) -> u64 { + let result = (self.s[1].wrapping_mul(5)).rotate_left(7).wrapping_mul(9); + let t = self.s[1] << 17; + self.s[2] ^= self.s[0]; + self.s[3] ^= self.s[1]; + self.s[1] ^= self.s[2]; + self.s[0] ^= self.s[3]; + self.s[2] ^= t; + self.s[3] = self.s[3].rotate_left(45); + result + } + + /// Generate uniform f64 in [0, 1). + fn next_f64(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64) + } + + /// Box-Muller transform for standard normal. + fn next_normal(&mut self) -> f64 { + let u1 = self.next_f64().max(1e-15); + let u2 = self.next_f64(); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() + } +} + +/// Core Monte Carlo simulation function. +/// +/// # Arguments +/// * `returns` - Per-strategy daily returns (N strategies x T days each) +/// * `weights` - Portfolio weights (length N, must sum to 1) +/// * `correlation_matrix` - N x N correlation matrix +/// * `initial_value` - Starting portfolio value +/// * `config` - Simulation configuration +pub fn simulate_portfolio_forward( + returns: &[Vec], + weights: &[f64], + correlation_matrix: &[Vec], + initial_value: f64, + config: &MonteCarloConfig, +) -> MonteCarloResult { + let n_assets = returns.len(); + let dt = 1.0; // daily time step + + // Compute per-asset mean and std of historical returns + let mut mus = vec![0.0; n_assets]; + let mut sigmas = vec![0.0; n_assets]; + for (i, ret) in returns.iter().enumerate() { + if ret.is_empty() { + continue; + } + let mean = ret.iter().sum::() / ret.len() as f64; + let var = ret.iter().map(|r| (r - mean).powi(2)).sum::() / ret.len() as f64; + mus[i] = mean; + sigmas[i] = var.sqrt().max(1e-10); + } + + // Cholesky decomposition of correlation matrix + let chol = cholesky(correlation_matrix).unwrap_or_else(|_| { + // Fallback: identity matrix (independent assets) + let mut identity = vec![vec![0.0; n_assets]; n_assets]; + for i in 0..n_assets { + identity[i][i] = 1.0; + } + identity + }); + + // Prepare a base RNG and create per-chunk seeds via jumping + let mut base_rng = Xoshiro256::new(config.seed); + let n_chunks = rayon::current_num_threads().max(1); + let chunk_size = (config.n_simulations + n_chunks - 1) / n_chunks; + + let chunk_rngs: Vec = (0..n_chunks) + .map(|_| { + let rng = base_rng.clone(); + base_rng.jump(); + rng + }) + .collect(); + + // Run simulations in parallel chunks + let all_paths: Vec> = chunk_rngs + .into_par_iter() + .enumerate() + .flat_map(|(chunk_idx, mut rng)| { + let start = chunk_idx * chunk_size; + let end = (start + chunk_size).min(config.n_simulations); + let mut chunk_paths = Vec::with_capacity(end - start); + + for _ in start..end { + let mut portfolio_value = initial_value; + let mut path = Vec::with_capacity(config.horizon_days + 1); + path.push(portfolio_value); + + for _ in 0..config.horizon_days { + // Generate N independent standard normals + let z_indep: Vec = (0..n_assets).map(|_| rng.next_normal()).collect(); + + // Correlate via Cholesky: z_corr = L * z_indep + let mut z_corr = vec![0.0; n_assets]; + for i in 0..n_assets { + for j in 0..=i { + z_corr[i] += chol[i][j] * z_indep[j]; + } + } + + // GBM per asset, then weighted portfolio return + let mut portfolio_return = 0.0; + for i in 0..n_assets { + let drift = (mus[i] - 0.5 * sigmas[i].powi(2)) * dt; + let diffusion = sigmas[i] * dt.sqrt() * z_corr[i]; + let asset_return = (drift + diffusion).exp() - 1.0; + portfolio_return += weights[i] * asset_return; + } + + portfolio_value *= 1.0 + portfolio_return; + path.push(portfolio_value); + } + + chunk_paths.push(path); + } + + chunk_paths + }) + .collect(); + + // Extract final values + let mut final_values: Vec = all_paths.iter().map(|p| *p.last().unwrap()).collect(); + final_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + let n = final_values.len(); + + // Percentile paths: find simulations closest to each percentile's final value + let percentiles = [5.0, 25.0, 50.0, 75.0, 95.0]; + let percentile_paths: Vec<(f64, Vec)> = percentiles + .iter() + .map(|&pct| { + let idx = ((pct / 100.0) * (n as f64 - 1.0)).round() as usize; + let target_final = final_values[idx.min(n - 1)]; + + // Find the simulation path whose final value is closest to target + let best_idx = all_paths + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + let da = (a.last().unwrap() - target_final).abs(); + let db = (b.last().unwrap() - target_final).abs(); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(i, _)| i) + .unwrap_or(0); + + (pct, all_paths[best_idx].clone()) + }) + .collect(); + + // Expected return (annualized from mean of final values) + let mean_final = final_values.iter().sum::() / n as f64; + let expected_return = (mean_final / initial_value - 1.0) * 100.0; + + // Probability of loss + let n_loss = final_values.iter().filter(|&&v| v < initial_value).count(); + let probability_of_loss = n_loss as f64 / n as f64; + + // VaR 95%: 5th percentile loss + let p5_idx = ((0.05 * (n as f64 - 1.0)).round() as usize).min(n - 1); + let var_95 = ((initial_value - final_values[p5_idx]) / initial_value * 100.0).max(0.0); + + // CVaR 95%: average of losses below VaR + let cvar_values = &final_values[..=p5_idx]; + let cvar_95 = if cvar_values.is_empty() { + var_95 + } else { + let avg_tail = cvar_values.iter().sum::() / cvar_values.len() as f64; + ((initial_value - avg_tail) / initial_value * 100.0).max(0.0) + }; + + MonteCarloResult { + percentile_paths, + final_values, + expected_return, + probability_of_loss, + var_95, + cvar_95, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cholesky_identity() { + let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]]; + let l = cholesky(&matrix).unwrap(); + assert!((l[0][0] - 1.0).abs() < 1e-10); + assert!((l[1][1] - 1.0).abs() < 1e-10); + assert!(l[0][1].abs() < 1e-10); + assert!(l[1][0].abs() < 1e-10); + } + + #[test] + fn test_cholesky_correlated() { + let matrix = vec![vec![1.0, 0.5], vec![0.5, 1.0]]; + let l = cholesky(&matrix).unwrap(); + // Verify L * L^T = matrix + let reconstructed_00 = l[0][0] * l[0][0]; + let reconstructed_01 = l[1][0] * l[0][0]; + let reconstructed_11 = l[1][0] * l[1][0] + l[1][1] * l[1][1]; + assert!((reconstructed_00 - 1.0).abs() < 1e-10); + assert!((reconstructed_01 - 0.5).abs() < 1e-10); + assert!((reconstructed_11 - 1.0).abs() < 1e-10); + } + + #[test] + fn test_simulate_basic() { + // Two assets with identical positive returns + let returns = vec![vec![0.001; 252], vec![0.001; 252]]; + let weights = vec![0.5, 0.5]; + let corr = vec![vec![1.0, 0.0], vec![0.0, 1.0]]; + let config = MonteCarloConfig { n_simulations: 100, horizon_days: 10, seed: 42 }; + + let result = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config); + + assert_eq!(result.final_values.len(), 100); + assert_eq!(result.percentile_paths.len(), 5); + // Expected return should be positive given positive drift + assert!(result.expected_return > -50.0); // Sanity check + } + + #[test] + fn test_deterministic() { + let returns = vec![vec![0.001; 100], vec![-0.0005; 100]]; + let weights = vec![0.6, 0.4]; + let corr = vec![vec![1.0, -0.3], vec![-0.3, 1.0]]; + let config = MonteCarloConfig { n_simulations: 50, horizon_days: 20, seed: 123 }; + + let r1 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config); + let r2 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config); + + // Same seed should produce same final values (single-threaded determinism) + // Note: with rayon, parallelism may affect order but not values + assert!((r1.expected_return - r2.expected_return).abs() < 1e-6); + } +} diff --git a/src/portfolio/position.rs b/src/portfolio/position.rs new file mode 100644 index 0000000..3e45097 --- /dev/null +++ b/src/portfolio/position.rs @@ -0,0 +1,347 @@ +//! Position tracking for portfolio management. + +use crate::core::types::{Direction, ExitReason, Position, Price, Timestamp, Trade}; + +/// Position manager for tracking open positions. +#[derive(Debug, Clone)] +pub struct PositionManager { + /// Current position state. + pub position: Position, + /// Trade counter for generating unique IDs. + trade_counter: u64, + /// Symbol being traded. + pub symbol: String, +} + +impl PositionManager { + /// Create a new position manager. + pub fn new(symbol: String) -> Self { + Self { position: Position::new(), trade_counter: 0, symbol } + } + + /// Check if currently in a position. + #[inline] + pub fn is_in_position(&self) -> bool { + self.position.is_open + } + + /// Get current position direction. + pub fn current_direction(&self) -> Option { + if self.position.is_open { + Some(self.position.direction) + } else { + None + } + } + + /// Open a new position. + /// + /// # Arguments + /// * `idx` - Bar index + /// * `timestamp` - Entry timestamp + /// * `price` - Entry price + /// * `size` - Position size + /// * `direction` - Trade direction + /// * `stop_price` - Optional stop-loss price + /// * `target_price` - Optional take-profit price + /// * `entry_fees` - Entry fees (to track for PnL calculation) + /// + /// # Returns + /// True if position was opened, false if already in position + pub fn open_position( + &mut self, + idx: usize, + _timestamp: Timestamp, + price: Price, + size: f64, + direction: Direction, + stop_price: Option, + target_price: Option, + entry_fees: f64, + ) -> bool { + if self.position.is_open { + return false; + } + + self.position.open(idx, price, size, direction, stop_price, target_price, entry_fees); + true + } + + /// Close current position and generate a trade record. + /// + /// # Arguments + /// * `idx` - Bar index + /// * `timestamp` - Exit timestamp + /// * `price` - Exit price + /// * `entry_timestamp` - Entry timestamp (for trade record) + /// * `exit_reason` - Reason for exit + /// * `fees` - Transaction fees + /// + /// # Returns + /// Trade record if position was closed, None if no position + pub fn close_position( + &mut self, + idx: usize, + timestamp: Timestamp, + price: Price, + entry_timestamp: Timestamp, + exit_reason: ExitReason, + fees: f64, + ) -> Option { + if !self.position.is_open { + return None; + } + + let trade = self.create_trade(idx, timestamp, price, entry_timestamp, exit_reason, fees); + self.position.close(); + self.trade_counter += 1; + + Some(trade) + } + + /// Create a trade record from current position. + fn create_trade( + &self, + exit_idx: usize, + exit_timestamp: Timestamp, + exit_price: Price, + entry_timestamp: Timestamp, + exit_reason: ExitReason, + exit_fees: f64, + ) -> Trade { + let pos = &self.position; + let multiplier = pos.direction.multiplier(); + + // Calculate P&L: gross - entry_fees - exit_fees + let gross_pnl = (exit_price - pos.entry_price) * pos.size * multiplier; + let total_fees = pos.entry_fees + exit_fees; + let pnl = gross_pnl - total_fees; + + // Calculate return percentage + let cost_basis = pos.entry_price * pos.size; + let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 }; + + Trade { + id: self.trade_counter, + symbol: self.symbol.clone(), + entry_idx: pos.entry_idx, + exit_idx, + entry_price: pos.entry_price, + exit_price, + size: pos.size, + direction: pos.direction, + pnl, + return_pct, + entry_time: entry_timestamp, + exit_time: exit_timestamp, + fees: total_fees, + exit_reason, + } + } + + /// Update position with new price data (for trailing stops). + /// + /// # Arguments + /// * `high` - Current bar high + /// * `low` - Current bar low + pub fn update_price(&mut self, high: Price, low: Price) { + if self.position.is_open { + self.position.update_extremes(high, low); + } + } + + /// Calculate unrealized P&L at current price. + pub fn unrealized_pnl(&self, current_price: Price) -> f64 { + self.position.unrealized_pnl(current_price) + } + + /// Get current position value (market value of position). + pub fn position_value(&self, current_price: Price) -> f64 { + if !self.position.is_open { + return 0.0; + } + current_price * self.position.size + } + + /// Calculate position exposure (notional value as fraction of given capital). + pub fn exposure(&self, current_price: Price, capital: f64) -> f64 { + if capital <= 0.0 { + return 0.0; + } + self.position_value(current_price) / capital + } + + /// Check if stop-loss is hit. + pub fn is_stop_hit(&self, low: Price, high: Price) -> bool { + if !self.position.is_open { + return false; + } + + if let Some(stop) = self.position.stop_price { + match self.position.direction { + Direction::Long => low <= stop, + Direction::Short => high >= stop, + } + } else { + false + } + } + + /// Check if take-profit is hit. + pub fn is_target_hit(&self, low: Price, high: Price) -> bool { + if !self.position.is_open { + return false; + } + + if let Some(target) = self.position.target_price { + match self.position.direction { + Direction::Long => high >= target, + Direction::Short => low <= target, + } + } else { + false + } + } + + /// Update trailing stop. + /// + /// # Arguments + /// * `trail_percent` - Trailing stop percentage + pub fn update_trailing_stop(&mut self, trail_percent: f64) { + if !self.position.is_open { + return; + } + + match self.position.direction { + Direction::Long => { + // Trail below highest price since entry + let new_stop = self.position.highest_since_entry * (1.0 - trail_percent); + if let Some(current_stop) = self.position.stop_price { + if new_stop > current_stop { + self.position.stop_price = Some(new_stop); + } + } else { + self.position.stop_price = Some(new_stop); + } + } + Direction::Short => { + // Trail above lowest price since entry + let new_stop = self.position.lowest_since_entry * (1.0 + trail_percent); + if let Some(current_stop) = self.position.stop_price { + if new_stop < current_stop { + self.position.stop_price = Some(new_stop); + } + } else { + self.position.stop_price = Some(new_stop); + } + } + } + } + + /// Reset position manager for new backtest. + pub fn reset(&mut self) { + self.position = Position::new(); + self.trade_counter = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_open_close_position() { + let mut pm = PositionManager::new("TEST".to_string()); + + // Open position + assert!(pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0)); + assert!(pm.is_in_position()); + + // Try to open another - should fail + assert!(!pm.open_position(1, 1001, 101.0, 10.0, Direction::Long, None, None, 0.0)); + + // Close position with profit + let trade = pm.close_position(5, 1005, 110.0, 1000, ExitReason::Signal, 2.0).unwrap(); + + assert!(!pm.is_in_position()); + assert_eq!(trade.entry_idx, 0); + assert_eq!(trade.exit_idx, 5); + assert!((trade.entry_price - 100.0).abs() < 1e-10); + assert!((trade.exit_price - 110.0).abs() < 1e-10); + + // P&L: (110 - 100) * 10 - 2 = 98 + assert!((trade.pnl - 98.0).abs() < 1e-10); + } + + #[test] + fn test_short_position() { + let mut pm = PositionManager::new("TEST".to_string()); + + pm.open_position(0, 1000, 100.0, 10.0, Direction::Short, None, None, 0.0); + + // Close with profit (price went down) + let trade = pm.close_position(5, 1005, 90.0, 1000, ExitReason::Signal, 2.0).unwrap(); + + // P&L: (100 - 90) * 10 * -(-1) - 2 = 98 + // For short: (entry - exit) * size = (100 - 90) * 10 = 100 gross, minus 2 fees = 98 + assert!((trade.pnl - 98.0).abs() < 1e-10); + } + + #[test] + fn test_stop_loss() { + let mut pm = PositionManager::new("TEST".to_string()); + + pm.open_position( + 0, + 1000, + 100.0, + 10.0, + Direction::Long, + Some(95.0), // Stop at 95 + None, + 0.0, + ); + + // Check stop not hit + assert!(!pm.is_stop_hit(96.0, 102.0)); + + // Check stop hit + assert!(pm.is_stop_hit(94.0, 102.0)); + } + + #[test] + fn test_trailing_stop() { + let mut pm = PositionManager::new("TEST".to_string()); + + pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0); + + // Update with higher price + pm.update_price(110.0, 98.0); + pm.update_trailing_stop(0.05); // 5% trail + + // Stop should be at 110 * 0.95 = 104.5 + assert!((pm.position.stop_price.unwrap() - 104.5).abs() < 1e-10); + + // Update with even higher price + pm.update_price(120.0, 108.0); + pm.update_trailing_stop(0.05); + + // Stop should move up to 120 * 0.95 = 114 + assert!((pm.position.stop_price.unwrap() - 114.0).abs() < 1e-10); + } + + #[test] + fn test_unrealized_pnl() { + let mut pm = PositionManager::new("TEST".to_string()); + + pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0); + + // Price up + let pnl = pm.unrealized_pnl(110.0); + assert!((pnl - 100.0).abs() < 1e-10); // (110 - 100) * 10 = 100 + + // Price down + let pnl = pm.unrealized_pnl(95.0); + assert!((pnl - (-50.0)).abs() < 1e-10); // (95 - 100) * 10 = -50 + } +} diff --git a/src/python/bindings.rs b/src/python/bindings.rs new file mode 100644 index 0000000..ff63a4f --- /dev/null +++ b/src/python/bindings.rs @@ -0,0 +1,2751 @@ +//! PyO3 function bindings for RaptorBT. + +use numpy::{PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +use std::collections::HashMap; + +use crate::core::types::{ + BacktestConfig, CompiledSignals, Direction, InstrumentConfig, OhlcvData, StopConfig, + TargetConfig, +}; +use crate::indicators; +use crate::signals::synchronizer::SyncMode; +use crate::strategies::basket::{BasketBacktest, BasketConfig}; +use crate::strategies::multi::{CombineMode, MultiStrategyBacktest, MultiStrategyConfig}; +use crate::strategies::options::{ + OptionType, OptionsBacktest, OptionsConfig, SizeType, StrikeSelection, +}; +use crate::strategies::pairs::{PairsBacktest, PairsConfig}; +use crate::strategies::single::SingleBacktest; +use crate::strategies::spreads::{ + LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType, +}; +use crate::strategies::tick::{TickBacktest, TickBacktestConfig}; + +use super::numpy_bridge::*; + +// ============================================================================ +// Configuration Classes +// ============================================================================ + +/// Python-exposed backtest configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyBacktestConfig { + #[pyo3(get, set)] + pub initial_capital: f64, + #[pyo3(get, set)] + pub fees: f64, + #[pyo3(get, set)] + pub slippage: f64, + #[pyo3(get, set)] + pub upon_bar_close: bool, + stop_config: StopConfig, + target_config: TargetConfig, +} + +#[pymethods] +impl PyBacktestConfig { + #[new] + #[pyo3(signature = (initial_capital=100000.0, fees=0.001, slippage=0.0, upon_bar_close=true))] + fn new(initial_capital: f64, fees: f64, slippage: f64, upon_bar_close: bool) -> Self { + Self { + initial_capital, + fees, + slippage, + upon_bar_close, + stop_config: StopConfig::None, + target_config: TargetConfig::None, + } + } + + /// Set fixed percentage stop-loss. + fn set_fixed_stop(&mut self, percent: f64) { + self.stop_config = StopConfig::Fixed { percent }; + } + + /// Set ATR-based stop-loss. + fn set_atr_stop(&mut self, multiplier: f64, period: usize) { + self.stop_config = StopConfig::Atr { multiplier, period }; + } + + /// Set trailing stop-loss. + fn set_trailing_stop(&mut self, percent: f64) { + self.stop_config = StopConfig::Trailing { percent }; + } + + /// Set fixed percentage take-profit. + fn set_fixed_target(&mut self, percent: f64) { + self.target_config = TargetConfig::Fixed { percent }; + } + + /// Set ATR-based take-profit. + fn set_atr_target(&mut self, multiplier: f64, period: usize) { + self.target_config = TargetConfig::Atr { multiplier, period }; + } + + /// Set risk-reward based take-profit. + fn set_risk_reward_target(&mut self, ratio: f64) { + self.target_config = TargetConfig::RiskReward { ratio }; + } +} + +impl From<&PyBacktestConfig> for BacktestConfig { + fn from(py_config: &PyBacktestConfig) -> Self { + BacktestConfig { + initial_capital: py_config.initial_capital, + fees: py_config.fees, + slippage: py_config.slippage, + stop: py_config.stop_config, + target: py_config.target_config, + upon_bar_close: py_config.upon_bar_close, + } + } +} + +/// Python-exposed per-instrument configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyInstrumentConfig { + #[pyo3(get, set)] + pub lot_size: Option, + #[pyo3(get, set)] + pub alloted_capital: Option, + #[pyo3(get, set)] + pub existing_qty: Option, + #[pyo3(get, set)] + pub avg_price: Option, + stop_config: Option, + target_config: Option, +} + +#[pymethods] +impl PyInstrumentConfig { + #[new] + #[pyo3(signature = (lot_size=None, alloted_capital=None, existing_qty=None, avg_price=None))] + fn new( + lot_size: Option, + alloted_capital: Option, + existing_qty: Option, + avg_price: Option, + ) -> Self { + Self { + lot_size, + alloted_capital, + existing_qty, + avg_price, + stop_config: None, + target_config: None, + } + } + + /// Set fixed percentage stop-loss override. + fn set_fixed_stop(&mut self, percent: f64) { + self.stop_config = Some(StopConfig::Fixed { percent }); + } + + /// Set ATR-based stop-loss override. + fn set_atr_stop(&mut self, multiplier: f64, period: usize) { + self.stop_config = Some(StopConfig::Atr { multiplier, period }); + } + + /// Set trailing stop-loss override. + fn set_trailing_stop(&mut self, percent: f64) { + self.stop_config = Some(StopConfig::Trailing { percent }); + } + + /// Set fixed percentage take-profit override. + fn set_fixed_target(&mut self, percent: f64) { + self.target_config = Some(TargetConfig::Fixed { percent }); + } + + /// Set ATR-based take-profit override. + fn set_atr_target(&mut self, multiplier: f64, period: usize) { + self.target_config = Some(TargetConfig::Atr { multiplier, period }); + } + + /// Set risk-reward based take-profit override. + fn set_risk_reward_target(&mut self, ratio: f64) { + self.target_config = Some(TargetConfig::RiskReward { ratio }); + } + + fn __repr__(&self) -> String { + format!( + "InstrumentConfig(lot_size={:?}, alloted_capital={:?})", + self.lot_size, self.alloted_capital + ) + } +} + +impl From<&PyInstrumentConfig> for InstrumentConfig { + fn from(py_config: &PyInstrumentConfig) -> Self { + InstrumentConfig { + lot_size: py_config.lot_size, + alloted_capital: py_config.alloted_capital, + stop: py_config.stop_config, + target: py_config.target_config, + existing_qty: py_config.existing_qty, + avg_price: py_config.avg_price, + } + } +} + +/// Python-exposed stop configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyStopConfig { + #[pyo3(get, set)] + pub stop_type: String, + #[pyo3(get, set)] + pub percent: Option, + #[pyo3(get, set)] + pub multiplier: Option, + #[pyo3(get, set)] + pub period: Option, +} + +#[pymethods] +impl PyStopConfig { + #[new] + fn new() -> Self { + Self { stop_type: "none".to_string(), percent: None, multiplier: None, period: None } + } + + #[staticmethod] + fn fixed(percent: f64) -> Self { + Self { + stop_type: "fixed".to_string(), + percent: Some(percent), + multiplier: None, + period: None, + } + } + + #[staticmethod] + fn atr(multiplier: f64, period: usize) -> Self { + Self { + stop_type: "atr".to_string(), + percent: None, + multiplier: Some(multiplier), + period: Some(period), + } + } + + #[staticmethod] + fn trailing(percent: f64) -> Self { + Self { + stop_type: "trailing".to_string(), + percent: Some(percent), + multiplier: None, + period: None, + } + } +} + +/// Python-exposed target configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyTargetConfig { + #[pyo3(get, set)] + pub target_type: String, + #[pyo3(get, set)] + pub percent: Option, + #[pyo3(get, set)] + pub multiplier: Option, + #[pyo3(get, set)] + pub period: Option, + #[pyo3(get, set)] + pub ratio: Option, +} + +#[pymethods] +impl PyTargetConfig { + #[new] + fn new() -> Self { + Self { + target_type: "none".to_string(), + percent: None, + multiplier: None, + period: None, + ratio: None, + } + } + + #[staticmethod] + fn fixed(percent: f64) -> Self { + Self { + target_type: "fixed".to_string(), + percent: Some(percent), + multiplier: None, + period: None, + ratio: None, + } + } + + #[staticmethod] + fn atr(multiplier: f64, period: usize) -> Self { + Self { + target_type: "atr".to_string(), + percent: None, + multiplier: Some(multiplier), + period: Some(period), + ratio: None, + } + } + + #[staticmethod] + fn risk_reward(ratio: f64) -> Self { + Self { + target_type: "risk_reward".to_string(), + percent: None, + multiplier: None, + period: None, + ratio: Some(ratio), + } + } +} + +// ============================================================================ +// Result Classes +// ============================================================================ + +/// Python-exposed trade. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyTrade { + #[pyo3(get)] + pub id: u64, + #[pyo3(get)] + pub symbol: String, + #[pyo3(get)] + pub entry_idx: usize, + #[pyo3(get)] + pub exit_idx: usize, + #[pyo3(get)] + pub entry_price: f64, + #[pyo3(get)] + pub exit_price: f64, + #[pyo3(get)] + pub size: f64, + #[pyo3(get)] + pub direction: i32, + #[pyo3(get)] + pub pnl: f64, + #[pyo3(get)] + pub return_pct: f64, + #[pyo3(get)] + pub entry_time: i64, + #[pyo3(get)] + pub exit_time: i64, + #[pyo3(get)] + pub fees: f64, + #[pyo3(get)] + pub exit_reason: String, +} + +#[pymethods] +impl PyTrade { + fn __repr__(&self) -> String { + format!( + "Trade(symbol={}, entry={:.2}, exit={:.2}, pnl={:.2}, return={:.2}%)", + self.symbol, self.entry_price, self.exit_price, self.pnl, self.return_pct + ) + } +} + +/// Python-exposed backtest metrics. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyBacktestMetrics { + #[pyo3(get)] + pub total_return_pct: f64, + #[pyo3(get)] + pub sharpe_ratio: f64, + #[pyo3(get)] + pub sortino_ratio: f64, + #[pyo3(get)] + pub calmar_ratio: f64, + #[pyo3(get)] + pub omega_ratio: f64, + #[pyo3(get)] + pub max_drawdown_pct: f64, + #[pyo3(get)] + pub max_drawdown_duration: usize, + #[pyo3(get)] + pub win_rate_pct: f64, + #[pyo3(get)] + pub profit_factor: f64, + #[pyo3(get)] + pub expectancy: f64, + #[pyo3(get)] + pub sqn: f64, + #[pyo3(get)] + pub total_trades: usize, + #[pyo3(get)] + pub total_closed_trades: usize, + #[pyo3(get)] + pub total_open_trades: usize, + #[pyo3(get)] + pub open_trade_pnl: f64, + #[pyo3(get)] + pub winning_trades: usize, + #[pyo3(get)] + pub losing_trades: usize, + #[pyo3(get)] + pub start_value: f64, + #[pyo3(get)] + pub end_value: f64, + #[pyo3(get)] + pub total_fees_paid: f64, + #[pyo3(get)] + pub best_trade_pct: f64, + #[pyo3(get)] + pub worst_trade_pct: f64, + #[pyo3(get)] + pub avg_trade_return_pct: f64, + #[pyo3(get)] + pub avg_win_pct: f64, + #[pyo3(get)] + pub avg_loss_pct: f64, + #[pyo3(get)] + pub avg_winning_duration: f64, + #[pyo3(get)] + pub avg_losing_duration: f64, + #[pyo3(get)] + pub max_consecutive_wins: usize, + #[pyo3(get)] + pub max_consecutive_losses: usize, + #[pyo3(get)] + pub avg_holding_period: f64, + #[pyo3(get)] + pub exposure_pct: f64, + #[pyo3(get)] + pub payoff_ratio: f64, + #[pyo3(get)] + pub recovery_factor: f64, +} + +#[pymethods] +impl PyBacktestMetrics { + fn __repr__(&self) -> String { + format!( + "BacktestMetrics(return={:.2}%, sharpe={:.2}, max_dd={:.2}%, trades={})", + self.total_return_pct, self.sharpe_ratio, self.max_drawdown_pct, self.total_trades + ) + } + + /// Convert to dictionary of all metrics. + fn to_dict(&self, py: Python) -> PyResult { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("Start Value", self.start_value)?; + dict.set_item("End Value", self.end_value)?; + dict.set_item("Total Return [%]", self.total_return_pct)?; + dict.set_item("Total Fees Paid", self.total_fees_paid)?; + dict.set_item("Max Drawdown [%]", self.max_drawdown_pct)?; + dict.set_item("Max Drawdown Duration", self.max_drawdown_duration)?; + dict.set_item("Total Trades", self.total_trades)?; + dict.set_item("Total Closed Trades", self.total_closed_trades)?; + dict.set_item("Total Open Trades", self.total_open_trades)?; + dict.set_item("Open Trade PnL", self.open_trade_pnl)?; + dict.set_item("Win Rate [%]", self.win_rate_pct)?; + dict.set_item("Best Trade [%]", self.best_trade_pct)?; + dict.set_item("Worst Trade [%]", self.worst_trade_pct)?; + dict.set_item("Avg Winning Trade [%]", self.avg_win_pct)?; + dict.set_item("Avg Losing Trade [%]", self.avg_loss_pct)?; + dict.set_item("Avg Winning Trade Duration", self.avg_winning_duration)?; + dict.set_item("Avg Losing Trade Duration", self.avg_losing_duration)?; + dict.set_item("Profit Factor", self.profit_factor)?; + dict.set_item("Expectancy", self.expectancy)?; + dict.set_item("SQN", self.sqn)?; + dict.set_item("Sharpe Ratio", self.sharpe_ratio)?; + dict.set_item("Sortino Ratio", self.sortino_ratio)?; + dict.set_item("Calmar Ratio", self.calmar_ratio)?; + dict.set_item("Omega Ratio", self.omega_ratio)?; + Ok(dict.into()) + } +} + +/// Python-exposed backtest result. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyBacktestResult { + #[pyo3(get)] + pub metrics: PyBacktestMetrics, + equity_curve: Vec, + drawdown_curve: Vec, + trades: Vec, + returns: Vec, +} + +#[pymethods] +impl PyBacktestResult { + /// Get equity curve as numpy array. + fn equity_curve<'py>(&self, py: Python<'py>) -> &'py PyArray1 { + vec_to_numpy_f64(py, self.equity_curve.clone()) + } + + /// Get drawdown curve as numpy array. + fn drawdown_curve<'py>(&self, py: Python<'py>) -> &'py PyArray1 { + vec_to_numpy_f64(py, self.drawdown_curve.clone()) + } + + /// Get returns as numpy array. + fn returns<'py>(&self, py: Python<'py>) -> &'py PyArray1 { + vec_to_numpy_f64(py, self.returns.clone()) + } + + /// Get list of trades. + fn trades(&self) -> Vec { + self.trades.clone() + } + + fn __repr__(&self) -> String { + format!( + "BacktestResult(return={:.2}%, trades={}, max_dd={:.2}%)", + self.metrics.total_return_pct, self.metrics.total_trades, self.metrics.max_drawdown_pct + ) + } +} + +// ============================================================================ +// Backtest Functions +// ============================================================================ + +/// Run single instrument backtest. +#[pyfunction] +#[pyo3(signature = (timestamps, open, high, low, close, volume, entries, exits, direction=1, weight=1.0, symbol="UNKNOWN", config=None, position_sizes=None, instrument_config=None))] +pub fn run_single_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + direction: i32, + weight: f64, + symbol: &str, + config: Option<&PyBacktestConfig>, + position_sizes: Option>, + instrument_config: Option<&PyInstrumentConfig>, +) -> PyResult { + let ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(timestamps), + open: numpy_to_vec_f64(open), + high: numpy_to_vec_f64(high), + low: numpy_to_vec_f64(low), + close: numpy_to_vec_f64(close), + volume: numpy_to_vec_f64(volume), + }; + + let dir = Direction::from_int(direction).unwrap_or(Direction::Long); + + let signals = CompiledSignals { + symbol: symbol.to_string(), + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: position_sizes.map(numpy_to_vec_f64), + direction: dir, + weight, + }; + + let rust_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default(); + let inst_config = instrument_config.map(InstrumentConfig::from); + + let backtest = SingleBacktest::new(rust_config); + let result = backtest.run_with_instrument_config(&ohlcv, &signals, inst_config.as_ref()); + + Ok(convert_result(result)) +} + +/// Run basket/collective backtest. +#[pyfunction] +#[pyo3(signature = (instruments, config=None, sync_mode="all", instrument_configs=None))] +pub fn run_basket_backtest<'py>( + _py: Python<'py>, + instruments: Vec<( + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + i32, + f64, + String, + )>, + config: Option<&PyBacktestConfig>, + sync_mode: &str, + instrument_configs: Option>, +) -> PyResult { + let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments + .into_iter() + .map(|(ts, o, h, l, c, v, entries, exits, dir, weight, sym)| { + let ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(ts), + open: numpy_to_vec_f64(o), + high: numpy_to_vec_f64(h), + low: numpy_to_vec_f64(l), + close: numpy_to_vec_f64(c), + volume: numpy_to_vec_f64(v), + }; + let signals = CompiledSignals { + symbol: sym, + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: None, + direction: Direction::from_int(dir).unwrap_or(Direction::Long), + weight, + }; + (ohlcv, signals) + }) + .collect(); + + let mode = match sync_mode { + "any" => SyncMode::Any, + "majority" => SyncMode::Majority, + "master" => SyncMode::Master, + _ => SyncMode::All, + }; + + let basket_config = BasketConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + sync_mode: mode, + ..Default::default() + }; + + // Convert PyInstrumentConfig map to InstrumentConfig map + let rust_inst_configs: Option> = + instrument_configs.map(|configs| { + configs.iter().map(|(k, v)| (k.clone(), InstrumentConfig::from(v))).collect() + }); + + let backtest = BasketBacktest::new(basket_config); + let result = + backtest.run_with_instrument_configs(&rust_instruments, rust_inst_configs.as_ref()); + + Ok(convert_result(result)) +} + +/// Run options backtest. +#[pyfunction] +#[pyo3(signature = (timestamps, open, high, low, close, volume, option_prices, entries, exits, direction=1, symbol="OPTION", config=None, option_type="call", strike_selection="atm", size_type="percent", size_value=1.0, lot_size=1, strike_interval=50.0))] +pub fn run_options_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + option_prices: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + direction: i32, + symbol: &str, + config: Option<&PyBacktestConfig>, + option_type: &str, + strike_selection: &str, + size_type: &str, + size_value: f64, + lot_size: usize, + strike_interval: f64, +) -> PyResult { + let ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(timestamps), + open: numpy_to_vec_f64(open), + high: numpy_to_vec_f64(high), + low: numpy_to_vec_f64(low), + close: numpy_to_vec_f64(close), + volume: numpy_to_vec_f64(volume), + }; + + let opt_prices = numpy_to_vec_f64(option_prices); + + let dir = Direction::from_int(direction).unwrap_or(Direction::Long); + + let signals = CompiledSignals { + symbol: symbol.to_string(), + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: None, + direction: dir, + weight: 1.0, + }; + + let opt_type = match option_type { + "put" => OptionType::Put, + _ => OptionType::Call, + }; + + let strike_sel = match strike_selection { + "otm1" => StrikeSelection::Otm(1), + "otm2" => StrikeSelection::Otm(2), + "itm1" => StrikeSelection::Itm(1), + "itm2" => StrikeSelection::Itm(2), + _ => StrikeSelection::Atm, + }; + + let size = match size_type { + "contracts" => SizeType::Contracts(size_value as usize), + "notional" => SizeType::Notional(size_value), + "risk" => SizeType::RiskPercent(size_value), + _ => SizeType::Percent(size_value), + }; + + let options_config = OptionsConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + option_type: opt_type, + strike_selection: strike_sel, + size_type: size, + lot_size, + strike_interval, + target_dte: None, + }; + + let backtest = OptionsBacktest::new(options_config); + let result = backtest.run(&ohlcv, &opt_prices, &signals); + + Ok(convert_result(result)) +} + +/// Run pairs trading backtest. +#[pyfunction] +#[pyo3(signature = (leg1_timestamps, leg1_open, leg1_high, leg1_low, leg1_close, leg1_volume, leg2_timestamps, leg2_open, leg2_high, leg2_low, leg2_close, leg2_volume, entries, exits, direction=1, symbol="PAIR", config=None, hedge_ratio=1.0, dynamic_hedge=false))] +pub fn run_pairs_backtest<'py>( + _py: Python<'py>, + leg1_timestamps: PyReadonlyArray1, + leg1_open: PyReadonlyArray1, + leg1_high: PyReadonlyArray1, + leg1_low: PyReadonlyArray1, + leg1_close: PyReadonlyArray1, + leg1_volume: PyReadonlyArray1, + leg2_timestamps: PyReadonlyArray1, + leg2_open: PyReadonlyArray1, + leg2_high: PyReadonlyArray1, + leg2_low: PyReadonlyArray1, + leg2_close: PyReadonlyArray1, + leg2_volume: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + direction: i32, + symbol: &str, + config: Option<&PyBacktestConfig>, + hedge_ratio: f64, + dynamic_hedge: bool, +) -> PyResult { + let leg1_ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(leg1_timestamps), + open: numpy_to_vec_f64(leg1_open), + high: numpy_to_vec_f64(leg1_high), + low: numpy_to_vec_f64(leg1_low), + close: numpy_to_vec_f64(leg1_close), + volume: numpy_to_vec_f64(leg1_volume), + }; + + let leg2_ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(leg2_timestamps), + open: numpy_to_vec_f64(leg2_open), + high: numpy_to_vec_f64(leg2_high), + low: numpy_to_vec_f64(leg2_low), + close: numpy_to_vec_f64(leg2_close), + volume: numpy_to_vec_f64(leg2_volume), + }; + + let dir = Direction::from_int(direction).unwrap_or(Direction::Long); + + let signals = CompiledSignals { + symbol: symbol.to_string(), + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: None, + direction: dir, + weight: 1.0, + }; + + let pairs_config = PairsConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + hedge_ratio, + dynamic_hedge, + ..Default::default() + }; + + let backtest = PairsBacktest::new(pairs_config); + let result = backtest.run(&leg1_ohlcv, &leg2_ohlcv, &signals); + + Ok(convert_result(result)) +} + +/// Run spread backtest (multi-leg options). +#[pyfunction] +#[pyo3(signature = (timestamps, underlying_close, legs_premiums, leg_configs, entries, exits, config=None, spread_type="custom", max_loss=None, target_profit=None, leg_expiry_timestamps=None))] +pub fn run_spread_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + underlying_close: PyReadonlyArray1, + legs_premiums: Vec>, + leg_configs: Vec<(String, f64, i32, usize)>, // (option_type, strike, quantity, lot_size) + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + config: Option<&PyBacktestConfig>, + spread_type: &str, + max_loss: Option, + target_profit: Option, + leg_expiry_timestamps: Option>, +) -> PyResult { + let ts = numpy_to_vec_i64(timestamps); + let underlying = numpy_to_vec_f64(underlying_close); + let premiums: Vec> = legs_premiums.into_iter().map(numpy_to_vec_f64).collect(); + let entry_signals = numpy_to_vec_bool(entries); + let exit_signals = numpy_to_vec_bool(exits); + + // Convert leg configs + let rust_leg_configs: Vec = leg_configs + .into_iter() + .map(|(opt_type, strike, quantity, lot_size)| { + let option_type = + SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call); + LegConfig::new(option_type, strike, quantity, lot_size) + }) + .collect(); + + // Parse spread type + let spread_type_enum = match spread_type.to_lowercase().as_str() { + "straddle" => SpreadType::Straddle, + "strangle" => SpreadType::Strangle, + "vertical_call" | "verticalcall" => SpreadType::VerticalCall, + "vertical_put" | "verticalput" => SpreadType::VerticalPut, + "iron_condor" | "ironcondor" => SpreadType::IronCondor, + "iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly, + "butterfly_call" | "butterflycall" => SpreadType::ButterflyCall, + "butterfly_put" | "butterflyput" => SpreadType::ButterflyPut, + "calendar" => SpreadType::Calendar, + "diagonal" => SpreadType::Diagonal, + "long_call" | "longcall" => SpreadType::LongCall, + "long_put" | "longput" => SpreadType::LongPut, + "naked_call" | "nakedcall" => SpreadType::NakedCall, + "naked_put" | "nakedput" => SpreadType::NakedPut, + _ => SpreadType::Custom, + }; + + let spread_config = SpreadConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + spread_type: spread_type_enum, + leg_configs: rust_leg_configs, + max_loss, + target_profit, + close_at_eod: false, + leg_expiry_timestamps, + }; + + let backtest = SpreadBacktest::new(spread_config); + let result = backtest.run(&ts, &underlying, &premiums, &entry_signals, &exit_signals); + + Ok(convert_result(result)) +} + +/// A single spread backtest item for batch execution. +#[pyclass] +#[derive(Clone)] +pub struct PyBatchSpreadItem { + #[pyo3(get, set)] + pub strategy_id: String, + pub legs_premiums: Vec>, + pub leg_configs: Vec<(String, f64, i32, usize)>, + pub entries: Vec, + pub exits: Vec, + #[pyo3(get, set)] + pub spread_type: String, + #[pyo3(get, set)] + pub max_loss: Option, + #[pyo3(get, set)] + pub target_profit: Option, +} + +#[pymethods] +impl PyBatchSpreadItem { + #[new] + #[pyo3(signature = (strategy_id, legs_premiums, leg_configs, entries, exits, + spread_type="custom", max_loss=None, target_profit=None))] + fn new( + strategy_id: String, + legs_premiums: Vec>, + leg_configs: Vec<(String, f64, i32, usize)>, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + spread_type: &str, + max_loss: Option, + target_profit: Option, + ) -> Self { + Self { + strategy_id, + legs_premiums: legs_premiums.into_iter().map(numpy_to_vec_f64).collect(), + leg_configs, + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + spread_type: spread_type.to_string(), + max_loss, + target_profit, + } + } +} + +/// Run multiple spread backtests in parallel via Rayon. +/// +/// Shared data (timestamps, underlying_close) is converted once, then each +/// item is backtested on its own Rayon thread with the GIL released. +/// +/// Returns a Vec of (strategy_id, PyBacktestResult) tuples. +#[pyfunction] +#[pyo3(signature = (timestamps, underlying_close, items, config=None))] +pub fn batch_spread_backtest( + py: Python<'_>, + timestamps: PyReadonlyArray1, + underlying_close: PyReadonlyArray1, + items: Vec, + config: Option<&PyBacktestConfig>, +) -> PyResult> { + use rayon::prelude::*; + + // Convert shared data while holding GIL + let ts = numpy_to_vec_i64(timestamps); + let underlying = numpy_to_vec_f64(underlying_close); + let base_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default(); + + // Prepare each item into a self-contained struct for parallel execution + struct PreparedItem { + strategy_id: String, + premiums: Vec>, + entries: Vec, + exits: Vec, + spread_config: SpreadConfig, + } + + let prepared: Vec = items + .into_iter() + .map(|item| { + let rust_leg_configs: Vec = item + .leg_configs + .into_iter() + .map(|(opt_type, strike, quantity, lot_size)| { + let option_type = + SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call); + LegConfig::new(option_type, strike, quantity, lot_size) + }) + .collect(); + + let spread_type_enum = match item.spread_type.to_lowercase().as_str() { + "straddle" => SpreadType::Straddle, + "strangle" => SpreadType::Strangle, + "vertical_call" | "verticalcall" => SpreadType::VerticalCall, + "vertical_put" | "verticalput" => SpreadType::VerticalPut, + "iron_condor" | "ironcondor" => SpreadType::IronCondor, + "iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly, + "butterfly_call" | "butterflycall" => SpreadType::ButterflyCall, + "butterfly_put" | "butterflyput" => SpreadType::ButterflyPut, + "calendar" => SpreadType::Calendar, + "diagonal" => SpreadType::Diagonal, + "long_call" | "longcall" => SpreadType::LongCall, + "long_put" | "longput" => SpreadType::LongPut, + "naked_call" | "nakedcall" => SpreadType::NakedCall, + "naked_put" | "nakedput" => SpreadType::NakedPut, + _ => SpreadType::Custom, + }; + + let spread_config = SpreadConfig { + base: base_config.clone(), + spread_type: spread_type_enum, + leg_configs: rust_leg_configs.clone(), + max_loss: item.max_loss, + target_profit: item.target_profit, + close_at_eod: false, + leg_expiry_timestamps: None, + }; + + PreparedItem { + strategy_id: item.strategy_id, + premiums: item.legs_premiums, + entries: item.entries, + exits: item.exits, + spread_config, + } + }) + .collect(); + + // Release GIL and run all backtests in parallel via Rayon + let results: Vec<(String, crate::core::types::BacktestResult)> = py.allow_threads(|| { + prepared + .into_par_iter() + .map(|item| { + let backtest = SpreadBacktest::new(item.spread_config); + let result = + backtest.run(&ts, &underlying, &item.premiums, &item.entries, &item.exits); + (item.strategy_id, result) + }) + .collect() + }); + + // Re-acquire GIL and convert results to Python objects + Ok(results.into_iter().map(|(id, result)| (id, convert_result(result))).collect()) +} + +/// Run multi-strategy backtest. +#[pyfunction] +#[pyo3(signature = (timestamps, open, high, low, close, volume, strategies, config=None, combine_mode="any"))] +pub fn run_multi_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + strategies: Vec<(PyReadonlyArray1, PyReadonlyArray1, i32, f64, String)>, + config: Option<&PyBacktestConfig>, + combine_mode: &str, +) -> PyResult { + let ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(timestamps), + open: numpy_to_vec_f64(open), + high: numpy_to_vec_f64(high), + low: numpy_to_vec_f64(low), + close: numpy_to_vec_f64(close), + volume: numpy_to_vec_f64(volume), + }; + + let rust_strategies: Vec = strategies + .into_iter() + .map(|(entries, exits, dir, weight, symbol)| CompiledSignals { + symbol, + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: None, + direction: Direction::from_int(dir).unwrap_or(Direction::Long), + weight, + }) + .collect(); + + let mode = match combine_mode { + "all" => CombineMode::All, + "majority" => CombineMode::Majority, + "independent" => CombineMode::Independent, + "weighted" => CombineMode::Weighted, + _ => CombineMode::Any, + }; + + let multi_config = MultiStrategyConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + combine_mode: mode, + ..Default::default() + }; + + let backtest = MultiStrategyBacktest::new(multi_config); + let result = backtest.run(&ohlcv, &rust_strategies); + + Ok(convert_result(result)) +} + +/// Run tick-level backtest on a single instrument. +/// +/// All arrays must be the same length N (one element per tick). +/// `buy_qty_delta` and `sell_qty_delta` must already be per-tick deltas — +/// pass the difference from the previous tick, not Zerodha's cumulative totals. +/// `entries` / `exits` are caller-computed boolean signal arrays. +/// +/// Returns a `PyBacktestResult` with the same fields as `run_single_backtest`. +#[pyfunction] +#[pyo3(signature = ( + timestamps, + ltp, + bid, + ask, + buy_qty_delta, + sell_qty_delta, + oi, + entries, + exits, + symbol = "TICK", + initial_capital = 100_000.0, + fees = 0.001, + slippage = 0.0, + stop_loss_pct = 5.0, + take_profit_pct = 10.0, + max_hold_seconds = 1800_u64, + entry_cooldown_ticks = 10_usize, + max_trades = 50_usize, +))] +pub fn run_tick_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + ltp: PyReadonlyArray1, + bid: PyReadonlyArray1, + ask: PyReadonlyArray1, + buy_qty_delta: PyReadonlyArray1, + sell_qty_delta: PyReadonlyArray1, + oi: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + symbol: &str, + initial_capital: f64, + fees: f64, + slippage: f64, + stop_loss_pct: f64, + take_profit_pct: f64, + max_hold_seconds: u64, + entry_cooldown_ticks: usize, + max_trades: usize, +) -> PyResult { + let tick_data = crate::core::types::TickData { + timestamps: numpy_to_vec_i64(timestamps), + ltp: numpy_to_vec_f64(ltp), + bid: numpy_to_vec_f64(bid), + ask: numpy_to_vec_f64(ask), + buy_qty_delta: numpy_to_vec_f64(buy_qty_delta), + sell_qty_delta: numpy_to_vec_f64(sell_qty_delta), + oi: numpy_to_vec_f64(oi), + }; + + let entry_signals = numpy_to_vec_bool(entries); + let exit_signals = numpy_to_vec_bool(exits); + + let config = TickBacktestConfig { + base: crate::core::types::BacktestConfig { + initial_capital, + fees, + slippage, + stop: crate::core::types::StopConfig::None, + target: crate::core::types::TargetConfig::None, + upon_bar_close: false, + }, + stop_loss_pct, + take_profit_pct, + max_hold_seconds, + entry_cooldown_ticks, + max_trades, + }; + + let backtest = TickBacktest::new(config); + let result = backtest.run(&tick_data, &entry_signals, &exit_signals, symbol); + + Ok(convert_result(result)) +} + +// ============================================================================ +// Tick Signal Functions +// ============================================================================ + +/// Compute tick momentum entry signals from per-tick feature arrays. +/// +/// All input arrays must have the same length N. Returns a bool array of length N +/// where True indicates a valid entry tick (all gates passed, not in cooldown). +/// +/// Gates (each can be disabled by setting threshold to 0.0): +/// - spread_pct[i] <= spread_pct_max +/// - bsi_delta[i] >= bsi_min (0.0 = disabled) +/// - |return_1m[i]| >= return_1m_min_abs (0.0 = disabled; NaN always fails) +/// - cooldown_ticks between consecutive entries +/// +/// return_direction: +1 for long (needs positive return_1m), -1 for short. +#[pyfunction] +#[pyo3(signature = ( + spread_pct, + bsi_delta, + return_1m, + spread_pct_max = 5.0, + bsi_min = 0.0, + return_1m_min_abs = 0.0, + return_direction = 1_i8, + cooldown_ticks = 10_usize, +))] +pub fn compute_tick_entry_signals<'py>( + py: Python<'py>, + spread_pct: PyReadonlyArray1, + bsi_delta: PyReadonlyArray1, + return_1m: PyReadonlyArray1, + spread_pct_max: f64, + bsi_min: f64, + return_1m_min_abs: f64, + return_direction: i8, + cooldown_ticks: usize, +) -> PyResult<&'py PyArray1> { + let result = crate::signals::tick_signals::tick_momentum_entry( + &numpy_to_vec_f64(spread_pct), + &numpy_to_vec_f64(bsi_delta), + &numpy_to_vec_f64(return_1m), + spread_pct_max, + bsi_min, + return_1m_min_abs, + return_direction, + cooldown_ticks, + ); + Ok(vec_to_numpy_bool(py, result)) +} + +/// Compute time-based exit signals (EOD / session-end). +/// +/// Sets exit[i] = True for every tick with timestamp >= eod_exit_time_ns. +/// Set eod_exit_time_ns = 0 to disable (returns all False). +/// +/// timestamps_ns: nanoseconds-since-epoch for each tick (int64 array). +#[pyfunction] +#[pyo3(signature = (timestamps_ns, eod_exit_time_ns = 0_i64))] +pub fn compute_tick_exit_signals<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + eod_exit_time_ns: i64, +) -> PyResult<&'py PyArray1> { + let result = crate::signals::tick_signals::tick_momentum_exit( + &numpy_to_vec_i64(timestamps_ns), + eod_exit_time_ns, + ); + Ok(vec_to_numpy_bool(py, result)) +} + +// ============================================================================ +// Tick Feature Functions +// ============================================================================ + +/// Per-tick bid/ask spread as percentage of mid price. +/// Returns 0.0 where both bid and ask are zero. +#[pyfunction] +pub fn tick_spread_pct<'py>( + py: Python<'py>, + bid: PyReadonlyArray1, + ask: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::spread_pct(&numpy_to_vec_f64(bid), &numpy_to_vec_f64(ask)), + )) +} + +/// Per-tick delta BSI from Zerodha cumulative session totals. +/// +/// buy_qty_cumulative / sell_qty_cumulative must be the raw cumulative running sums +/// from Zerodha (NOT already-converted deltas). Returns [0, 1] per tick; 0.5 = neutral. +#[pyfunction] +pub fn buy_sell_imbalance_delta<'py>( + py: Python<'py>, + buy_qty_cumulative: PyReadonlyArray1, + sell_qty_cumulative: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::buy_sell_imbalance_delta( + &numpy_to_vec_f64(buy_qty_cumulative), + &numpy_to_vec_f64(sell_qty_cumulative), + ), + )) +} + +/// Per-tick lookback return over a time window. +/// +/// timestamps_ns: nanoseconds-since-epoch for each tick. +/// Returns NaN for ticks without sufficient history. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, ltp, window_seconds = 60.0))] +pub fn return_window<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + ltp: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::return_window( + &numpy_to_vec_i64(timestamps_ns), + &numpy_to_vec_f64(ltp), + window_seconds, + ), + )) +} + +/// Rolling realized volatility proxy: stddev of log-returns over a time window (as %). +/// Returns NaN for ticks without at least 2 data points in the window. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, ltp, window_seconds = 300.0))] +pub fn realized_vol_rolling<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + ltp: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::realized_vol_rolling( + &numpy_to_vec_i64(timestamps_ns), + &numpy_to_vec_f64(ltp), + window_seconds, + ), + )) +} + +/// Per-tick OI position within the day's high/low range: [0, 100]. +/// Returns NaN where oi_day_high <= oi_day_low. +#[pyfunction] +pub fn oi_position_pct<'py>( + py: Python<'py>, + oi: PyReadonlyArray1, + oi_day_high: f64, + oi_day_low: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::oi_position_pct( + &numpy_to_vec_f64(oi), + oi_day_high, + oi_day_low, + ), + )) +} + +/// Rolling tick velocity: ticks per minute over the preceding window_seconds. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, window_seconds = 60.0))] +pub fn tick_velocity<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::tick_velocity( + &numpy_to_vec_i64(timestamps_ns), + window_seconds, + ), + )) +} + +// ============================================================================ +// Indicator Functions +// ============================================================================ + +/// Simple Moving Average. +#[pyfunction] +pub fn sma<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::trend::sma(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Exponential Moving Average. +#[pyfunction] +pub fn ema<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::trend::ema(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Relative Strength Index. +#[pyfunction] +pub fn rsi<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::momentum::rsi(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// MACD indicator. +#[pyfunction] +#[pyo3(signature = (data, fast_period=12, slow_period=26, signal_period=9))] +pub fn macd<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let vec = numpy_to_vec_f64(data); + let result = indicators::momentum::macd(&vec, fast_period, slow_period, signal_period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.macd_line), + vec_to_numpy_f64(py, result.signal_line), + vec_to_numpy_f64(py, result.histogram), + )) +} + +/// Stochastic oscillator. +#[pyfunction] +#[pyo3(signature = (high, low, close, k_period=14, d_period=3))] +pub fn stochastic<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + k_period: usize, + d_period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::momentum::stochastic(&h, &l, &c, k_period, d_period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, result.k), vec_to_numpy_f64(py, result.d))) +} + +/// Average True Range. +#[pyfunction] +pub fn atr<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::volatility::atr(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Bollinger Bands. +#[pyfunction] +#[pyo3(signature = (data, period=20, std_dev=2.0))] +pub fn bollinger_bands<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, + std_dev: f64, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let vec = numpy_to_vec_f64(data); + let result = indicators::volatility::bollinger_bands(&vec, period, std_dev) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.upper), + vec_to_numpy_f64(py, result.middle), + vec_to_numpy_f64(py, result.lower), + )) +} + +/// Average Directional Index. +#[pyfunction] +pub fn adx<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::strength::adx(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Volume Weighted Average Price. +#[pyfunction] +pub fn vwap<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::volume::vwap(&h, &l, &c, &v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Supertrend indicator. +#[pyfunction] +#[pyo3(signature = (high, low, close, period=10, multiplier=3.0))] +pub fn supertrend<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, + multiplier: f64, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::trend::supertrend(&h, &l, &c, period, multiplier) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + + let direction_array = PyArray1::from_vec(py, result.direction); + Ok((vec_to_numpy_f64(py, result.supertrend), direction_array)) +} + +/// Rolling minimum (Lowest Low Value). +#[pyfunction] +pub fn rolling_min<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::rolling::rolling_min(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Rolling maximum (Highest High Value). +#[pyfunction] +pub fn rolling_max<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::rolling::rolling_max(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +// ============================================================================ +// Extended Indicator Functions (via ferro_ta_core) +// ============================================================================ + +/// Commodity Channel Index. +#[pyfunction] +pub fn cci<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::cci(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Williams %R. +#[pyfunction] +pub fn willr<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::willr(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Parabolic SAR. +#[pyfunction] +#[pyo3(signature = (high, low, acceleration=0.02, maximum=0.2))] +pub fn sar<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + acceleration: f64, + maximum: f64, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::sar(&h, &l, acceleration, maximum) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Plus Directional Indicator (+DI). +#[pyfunction] +pub fn plus_di<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::plus_di(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Minus Directional Indicator (-DI). +#[pyfunction] +pub fn minus_di<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::minus_di(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// ADX with +DI and -DI. +#[pyfunction] +pub fn adx_all<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::adx_all(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.adx), + vec_to_numpy_f64(py, result.plus_di), + vec_to_numpy_f64(py, result.minus_di), + )) +} + +/// Average Directional Movement Rating. +#[pyfunction] +pub fn adxr<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::adxr(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Rate of Change. +#[pyfunction] +pub fn roc<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::roc(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Money Flow Index. +#[pyfunction] +pub fn mfi<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::mfi(&h, &l, &c, &v, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Weighted Moving Average. +#[pyfunction] +pub fn wma<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::wma(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Double Exponential Moving Average. +#[pyfunction] +pub fn dema<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::dema(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Triple Exponential Moving Average. +#[pyfunction] +pub fn tema<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::tema(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Kaufman Adaptive Moving Average. +#[pyfunction] +pub fn kama<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::kama(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Stochastic RSI. +#[pyfunction] +#[pyo3(signature = (data, timeperiod=14, fastk_period=5, fastd_period=3))] +pub fn stochrsi<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + timeperiod: usize, + fastk_period: usize, + fastd_period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let vec = numpy_to_vec_f64(data); + let (fastk, fastd) = indicators::ferro_bridge::stochrsi(&vec, timeperiod, fastk_period, fastd_period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, fastk), vec_to_numpy_f64(py, fastd))) +} + +/// Aroon indicator (up, down). +#[pyfunction] +pub fn aroon<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::aroon(&h, &l, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, result.up), vec_to_numpy_f64(py, result.down))) +} + +/// TRIX indicator. +#[pyfunction] +pub fn trix<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::trix(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Normalized Average True Range. +#[pyfunction] +pub fn natr<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::natr(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// True Range. +#[pyfunction] +pub fn trange<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::trange(&h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Standard Deviation. +#[pyfunction] +#[pyo3(signature = (data, period, nbdev=1.0))] +pub fn stddev<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, + nbdev: f64, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::stddev(&vec, period, nbdev) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Variance. +#[pyfunction] +#[pyo3(signature = (data, period, nbdev=1.0))] +pub fn var<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, + nbdev: f64, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::var(&vec, period, nbdev) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Linear Regression. +#[pyfunction] +pub fn linearreg<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::linearreg(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Linear Regression Slope. +#[pyfunction] +pub fn linearreg_slope<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::linearreg_slope(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Linear Regression Intercept. +#[pyfunction] +pub fn linearreg_intercept<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::linearreg_intercept(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Linear Regression Angle. +#[pyfunction] +pub fn linearreg_angle<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::linearreg_angle(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Time Series Forecast. +#[pyfunction] +pub fn tsf<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::tsf(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Beta. +#[pyfunction] +pub fn beta<'py>( + py: Python<'py>, + data0: PyReadonlyArray1, + data1: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let v0 = numpy_to_vec_f64(data0); + let v1 = numpy_to_vec_f64(data1); + let result = indicators::ferro_bridge::beta(&v0, &v1, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Correlation. +#[pyfunction] +pub fn correl<'py>( + py: Python<'py>, + data0: PyReadonlyArray1, + data1: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let v0 = numpy_to_vec_f64(data0); + let v1 = numpy_to_vec_f64(data1); + let result = indicators::ferro_bridge::correl(&v0, &v1, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Chaikin A/D Line. +#[pyfunction] +pub fn ad<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::ad(&h, &l, &c, &v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Chaikin A/D Oscillator. +#[pyfunction] +#[pyo3(signature = (high, low, close, volume, fastperiod=3, slowperiod=10))] +pub fn adosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + fastperiod: usize, + slowperiod: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::adosc(&h, &l, &c, &v, fastperiod, slowperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// On Balance Volume (ferro_ta_core version). +#[pyfunction] +pub fn obv<'py>( + py: Python<'py>, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::obv(&c, &v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Momentum. +#[pyfunction] +pub fn mom<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::mom(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Percentage Price Oscillator. +#[pyfunction] +#[pyo3(signature = (data, fastperiod=12, slowperiod=26, signalperiod=9))] +pub fn ppo<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::ppo(&vec, fastperiod, slowperiod, signalperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.ppo_line), + vec_to_numpy_f64(py, result.signal_line), + vec_to_numpy_f64(py, result.histogram), + )) +} + +/// Chande Momentum Oscillator. +#[pyfunction] +pub fn cmo<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::cmo(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Aroon Oscillator. +#[pyfunction] +pub fn aroonosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::aroonosc(&h, &l, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Balance of Power. +#[pyfunction] +pub fn bop<'py>( + py: Python<'py>, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let o = numpy_to_vec_f64(open); + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::bop(&o, &h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Ultimate Oscillator. +#[pyfunction] +#[pyo3(signature = (high, low, close, period1=7, period2=14, period3=28))] +pub fn ultosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period1: usize, + period2: usize, + period3: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::ultosc(&h, &l, &c, period1, period2, period3) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Typical Price. +#[pyfunction] +pub fn typprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::typprice(&h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Median Price. +#[pyfunction] +pub fn medprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::medprice(&h, &l) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Average Price. +#[pyfunction] +pub fn avgprice<'py>( + py: Python<'py>, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let o = numpy_to_vec_f64(open); + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::avgprice(&o, &h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Weighted Close Price. +#[pyfunction] +pub fn wclprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::wclprice(&h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Midpoint over period. +#[pyfunction] +pub fn midpoint<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::midpoint(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Midprice over period. +#[pyfunction] +pub fn midprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::midprice(&h, &l, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Triple Exponential Moving Average (T3). +#[pyfunction] +#[pyo3(signature = (data, period=5, vfactor=0.7))] +pub fn t3<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, + vfactor: f64, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::t3(&vec, period, vfactor) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Triangular Moving Average. +#[pyfunction] +pub fn trima<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::trima(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Absolute Price Oscillator. +#[pyfunction] +#[pyo3(signature = (data, fastperiod=12, slowperiod=26))] +pub fn apo<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + fastperiod: usize, + slowperiod: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::apo(&vec, fastperiod, slowperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +// ============================================================================ +// Extended indicators (P0 batch) +// ============================================================================ + +/// Volume-Weighted Moving Average. +#[pyfunction] +#[pyo3(signature = (data, volume, period=20))] +pub fn vwma<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + volume: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let c = numpy_to_vec_f64(data); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::vwma(&c, &v, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Donchian Channels — returns (upper, middle, lower). +#[pyfunction] +pub fn donchian<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::donchian(&h, &l, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.upper), + vec_to_numpy_f64(py, result.middle), + vec_to_numpy_f64(py, result.lower), + )) +} + +/// Choppiness Index. +#[pyfunction] +#[pyo3(signature = (high, low, close, period=14))] +pub fn choppiness_index<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::choppiness_index(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Hull Moving Average. +#[pyfunction] +pub fn hull_ma<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::hull_ma(&v, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Chandelier Exit — returns (long_exit, short_exit). +#[pyfunction] +#[pyo3(signature = (high, low, close, period=22, multiplier=3.0))] +pub fn chandelier_exit<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, + multiplier: f64, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::chandelier_exit(&h, &l, &c, period, multiplier) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, result.long_exit), vec_to_numpy_f64(py, result.short_exit))) +} + +/// Ichimoku Cloud — returns (tenkan, kijun, senkou_a, senkou_b, chikou). +#[pyfunction] +#[pyo3(signature = (high, low, close, tenkan_period=9, kijun_period=26, senkou_b_period=52, displacement=26))] +#[allow(clippy::too_many_arguments)] +pub fn ichimoku<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, +) -> PyResult<( + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, +)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let r = indicators::ferro_bridge::ichimoku( + &h, &l, &c, tenkan_period, kijun_period, senkou_b_period, displacement, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, r.tenkan), + vec_to_numpy_f64(py, r.kijun), + vec_to_numpy_f64(py, r.senkou_a), + vec_to_numpy_f64(py, r.senkou_b), + vec_to_numpy_f64(py, r.chikou), + )) +} + +/// Pivot Points — method: "classic" | "fibonacci" | "camarilla". +/// +/// Returns (pivot, r1, s1, r2, s2). +#[pyfunction] +pub fn pivot_points<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + method: &str, +) -> PyResult<( + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, +)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let r = indicators::ferro_bridge::pivot_points(&h, &l, &c, method) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, r.pivot), + vec_to_numpy_f64(py, r.r1), + vec_to_numpy_f64(py, r.s1), + vec_to_numpy_f64(py, r.r2), + vec_to_numpy_f64(py, r.s2), + )) +} + +// ============================================================================ +// Hilbert Transform (cycle) indicators +// ============================================================================ + +/// Hilbert Transform — Instantaneous Trendline. +#[pyfunction] +pub fn ht_trendline<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_trendline(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Hilbert Transform — Dominant Cycle Period. +#[pyfunction] +pub fn ht_dcperiod<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_dcperiod(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Hilbert Transform — Dominant Cycle Phase (degrees). +#[pyfunction] +pub fn ht_dcphase<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_dcphase(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Hilbert Transform — Phasor Components (in_phase, quadrature). +#[pyfunction] +pub fn ht_phasor<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_phasor(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, r.in_phase), vec_to_numpy_f64(py, r.quadrature))) +} + +/// Hilbert Transform — Sine Wave (sine, lead_sine). +#[pyfunction] +pub fn ht_sine<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_sine(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, r.sine), vec_to_numpy_f64(py, r.lead_sine))) +} + +/// Hilbert Transform — Trend vs Cycle Mode. +/// +/// Returns i32 numpy array: 1 = trend, 0 = cycle. +#[pyfunction] +pub fn ht_trendmode<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_trendmode(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +// ============================================================================ +// Market regime detection +// ============================================================================ + +/// Trend/range regime from ADX. Returns i8: 1=trend, 0=range, -1=warmup. +#[pyfunction] +pub fn regime_adx<'py>( + py: Python<'py>, + adx: PyReadonlyArray1, + threshold: f64, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(adx); + let r = indicators::ferro_bridge::regime_adx(&v, threshold) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +/// Combined ADX + ATR-ratio regime. Returns i8: 1=trend, 0=range, -1=NaN. +#[pyfunction] +pub fn regime_combined<'py>( + py: Python<'py>, + adx: PyReadonlyArray1, + atr: PyReadonlyArray1, + close: PyReadonlyArray1, + adx_threshold: f64, + atr_pct_threshold: f64, +) -> PyResult<&'py PyArray1> { + let a = numpy_to_vec_f64(adx); + let t = numpy_to_vec_f64(atr); + let c = numpy_to_vec_f64(close); + let r = indicators::ferro_bridge::regime_combined(&a, &t, &c, adx_threshold, atr_pct_threshold) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +/// CUSUM-based structural break detection. Returns i8: 1 at break bars. +#[pyfunction] +pub fn detect_breaks_cusum<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + window: usize, + threshold: f64, + slack: f64, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::detect_breaks_cusum(&v, window, threshold, slack) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +/// Rolling variance break. Returns i8: 1 at break bars. +#[pyfunction] +pub fn rolling_variance_break<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + short_window: usize, + long_window: usize, + threshold: f64, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::rolling_variance_break(&v, short_window, long_window, threshold) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +// ============================================================================ +// Portfolio / cross-series tools +// ============================================================================ + +/// Rolling beta of asset vs benchmark. +#[pyfunction] +pub fn rolling_beta<'py>( + py: Python<'py>, + asset: PyReadonlyArray1, + benchmark: PyReadonlyArray1, + window: usize, +) -> PyResult<&'py PyArray1> { + let a = numpy_to_vec_f64(asset); + let b = numpy_to_vec_f64(benchmark); + let r = indicators::ferro_bridge::rolling_beta(&a, &b, window) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Drawdown series from an equity curve. Returns (per_bar_dd, max_dd). +#[pyfunction] +pub fn drawdown_series<'py>( + py: Python<'py>, + equity: PyReadonlyArray1, +) -> PyResult<(&'py PyArray1, f64)> { + let v = numpy_to_vec_f64(equity); + let r = indicators::ferro_bridge::drawdown_series(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, r.series), r.max_drawdown)) +} + +/// Rolling z-score. +#[pyfunction] +pub fn zscore_series<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + window: usize, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::zscore_series(&v, window) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Relative strength = asset - beta*benchmark (excess-return style). +#[pyfunction] +pub fn relative_strength<'py>( + py: Python<'py>, + asset_returns: PyReadonlyArray1, + benchmark_returns: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let a = numpy_to_vec_f64(asset_returns); + let b = numpy_to_vec_f64(benchmark_returns); + let r = indicators::ferro_bridge::relative_strength(&a, &b) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Spread = a - hedge*b. +#[pyfunction] +pub fn spread<'py>( + py: Python<'py>, + a: PyReadonlyArray1, + b: PyReadonlyArray1, + hedge: f64, +) -> PyResult<&'py PyArray1> { + let av = numpy_to_vec_f64(a); + let bv = numpy_to_vec_f64(b); + let r = indicators::ferro_bridge::spread(&av, &bv, hedge) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Ratio = a/b element-wise. +#[pyfunction] +pub fn ratio<'py>( + py: Python<'py>, + a: PyReadonlyArray1, + b: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let av = numpy_to_vec_f64(a); + let bv = numpy_to_vec_f64(b); + let r = indicators::ferro_bridge::ratio(&av, &bv) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +// ============================================================================ +// Monte Carlo Forward Simulation +// ============================================================================ + +/// Run Monte Carlo forward simulation for a portfolio. +/// +/// Uses Geometric Brownian Motion with Cholesky-decomposed correlated random +/// draws, parallelized via Rayon. +/// +/// # Arguments +/// * `returns` - List of per-strategy return arrays (N strategies) +/// * `weights` - Portfolio weight vector (length N, sums to 1) +/// * `correlation_matrix` - N x N correlation matrix (flattened row-major as 2D list) +/// * `initial_value` - Starting portfolio value +/// * `n_simulations` - Number of simulation paths (default: 10000) +/// * `horizon_days` - Forward simulation horizon in trading days (default: 252) +/// * `seed` - Random seed for reproducibility (default: 42) +#[pyfunction] +#[pyo3(signature = (returns, weights, correlation_matrix, initial_value, n_simulations=10000, horizon_days=252, seed=42))] +pub fn simulate_portfolio_mc( + py: Python<'_>, + returns: Vec>, + weights: PyReadonlyArray1<'_, f64>, + correlation_matrix: Vec>, + initial_value: f64, + n_simulations: usize, + horizon_days: usize, + seed: u64, +) -> PyResult { + use crate::portfolio::monte_carlo::{simulate_portfolio_forward, MonteCarloConfig}; + + // Convert numpy arrays to Rust vecs + let rust_returns: Vec> = + returns.iter().map(|arr| arr.as_slice().unwrap().to_vec()).collect(); + + let rust_weights: Vec = weights.as_slice().unwrap().to_vec(); + + let rust_corr: Vec> = + correlation_matrix.iter().map(|arr| arr.as_slice().unwrap().to_vec()).collect(); + + let config = MonteCarloConfig { n_simulations, horizon_days, seed }; + + // Run simulation (releases GIL for Rayon parallelism) + let result = py.allow_threads(|| { + simulate_portfolio_forward(&rust_returns, &rust_weights, &rust_corr, initial_value, &config) + }); + + // Build Python dict result + let dict = pyo3::types::PyDict::new(py); + + // percentile_paths: list of (percentile, list[float]) + let paths_list = pyo3::types::PyList::empty(py); + for (pct, path) in &result.percentile_paths { + let path_list = pyo3::types::PyList::new(py, path); + let tuple = pyo3::types::PyTuple::new(py, &[pct.to_object(py), path_list.to_object(py)]); + paths_list.append(tuple)?; + } + dict.set_item("percentile_paths", paths_list)?; + + // final_values as numpy array for efficiency + let final_arr = PyArray1::from_vec(py, result.final_values); + dict.set_item("final_values", final_arr)?; + + dict.set_item("expected_return", result.expected_return)?; + dict.set_item("probability_of_loss", result.probability_of_loss)?; + dict.set_item("var_95", result.var_95)?; + dict.set_item("cvar_95", result.cvar_95)?; + + Ok(dict.into()) +} + +/// Convert Rust BacktestResult to Python PyBacktestResult. +fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResult { + let metrics = PyBacktestMetrics { + total_return_pct: result.metrics.total_return_pct, + sharpe_ratio: result.metrics.sharpe_ratio, + sortino_ratio: result.metrics.sortino_ratio, + calmar_ratio: result.metrics.calmar_ratio, + omega_ratio: result.metrics.omega_ratio, + max_drawdown_pct: result.metrics.max_drawdown_pct, + max_drawdown_duration: result.metrics.max_drawdown_duration, + win_rate_pct: result.metrics.win_rate_pct, + profit_factor: result.metrics.profit_factor, + expectancy: result.metrics.expectancy, + sqn: result.metrics.sqn, + total_trades: result.metrics.total_trades, + total_closed_trades: result.metrics.total_closed_trades, + total_open_trades: result.metrics.total_open_trades, + open_trade_pnl: result.metrics.open_trade_pnl, + winning_trades: result.metrics.winning_trades, + losing_trades: result.metrics.losing_trades, + start_value: result.metrics.start_value, + end_value: result.metrics.end_value, + total_fees_paid: result.metrics.total_fees_paid, + best_trade_pct: result.metrics.best_trade_pct, + worst_trade_pct: result.metrics.worst_trade_pct, + avg_trade_return_pct: result.metrics.avg_trade_return_pct, + avg_win_pct: result.metrics.avg_win_pct, + avg_loss_pct: result.metrics.avg_loss_pct, + avg_winning_duration: result.metrics.avg_winning_duration, + avg_losing_duration: result.metrics.avg_losing_duration, + max_consecutive_wins: result.metrics.max_consecutive_wins, + max_consecutive_losses: result.metrics.max_consecutive_losses, + avg_holding_period: result.metrics.avg_holding_period, + exposure_pct: result.metrics.exposure_pct, + payoff_ratio: result.metrics.payoff_ratio, + recovery_factor: result.metrics.recovery_factor, + }; + + let trades: Vec = result + .trades + .into_iter() + .map(|t| PyTrade { + id: t.id, + symbol: t.symbol, + entry_idx: t.entry_idx, + exit_idx: t.exit_idx, + entry_price: t.entry_price, + exit_price: t.exit_price, + size: t.size, + direction: t.direction as i32, + pnl: t.pnl, + return_pct: t.return_pct, + entry_time: t.entry_time, + exit_time: t.exit_time, + fees: t.fees, + exit_reason: format!("{:?}", t.exit_reason), + }) + .collect(); + + PyBacktestResult { + metrics, + equity_curve: result.equity_curve, + drawdown_curve: result.drawdown_curve, + trades, + returns: result.returns, + } +} \ No newline at end of file diff --git a/src/python/mod.rs b/src/python/mod.rs new file mode 100644 index 0000000..c4527d9 --- /dev/null +++ b/src/python/mod.rs @@ -0,0 +1,4 @@ +//! Python bindings for RaptorBT. + +pub mod bindings; +pub mod numpy_bridge; diff --git a/src/python/numpy_bridge.rs b/src/python/numpy_bridge.rs new file mode 100644 index 0000000..9770b53 --- /dev/null +++ b/src/python/numpy_bridge.rs @@ -0,0 +1,34 @@ +//! Zero-copy numpy array interface. + +use numpy::{PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Convert numpy array to Vec. +pub fn numpy_to_vec_f64(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert numpy array to Vec. +pub fn numpy_to_vec_i64(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert numpy bool array to Vec. +pub fn numpy_to_vec_bool(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_f64<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_i64<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_bool<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} diff --git a/src/signals/expression.rs b/src/signals/expression.rs new file mode 100644 index 0000000..6d83f6b --- /dev/null +++ b/src/signals/expression.rs @@ -0,0 +1,456 @@ +//! Expression evaluation for signal generation. +//! +//! Provides a Rust-native expression evaluator for generating trading signals +//! from indicator values. + +/// Comparison operators for signal generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompareOp { + /// Greater than. + Gt, + /// Greater than or equal. + Gte, + /// Less than. + Lt, + /// Less than or equal. + Lte, + /// Equal (within tolerance). + Eq, + /// Not equal. + Ne, +} + +/// Crossover/crossunder detection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrossType { + /// Line A crosses above line B. + CrossOver, + /// Line A crosses below line B. + CrossUnder, +} + +/// Compare two series element-wise. +/// +/// # Arguments +/// * `a` - First series +/// * `b` - Second series +/// * `op` - Comparison operator +/// +/// # Returns +/// Boolean series indicating where comparison is true +pub fn compare(a: &[f64], b: &[f64], op: CompareOp) -> Vec { + let n = a.len(); + assert_eq!(n, b.len()); + + let tolerance = 1e-10; + + let mut result = vec![false; n]; + for i in 0..n { + if a[i].is_nan() || b[i].is_nan() { + continue; + } + result[i] = match op { + CompareOp::Gt => a[i] > b[i], + CompareOp::Gte => a[i] >= b[i], + CompareOp::Lt => a[i] < b[i], + CompareOp::Lte => a[i] <= b[i], + CompareOp::Eq => (a[i] - b[i]).abs() < tolerance, + CompareOp::Ne => (a[i] - b[i]).abs() >= tolerance, + }; + } + + result +} + +/// Compare series with a scalar value. +/// +/// # Arguments +/// * `a` - Series +/// * `value` - Scalar value to compare against +/// * `op` - Comparison operator +/// +/// # Returns +/// Boolean series indicating where comparison is true +pub fn compare_scalar(a: &[f64], value: f64, op: CompareOp) -> Vec { + let n = a.len(); + let tolerance = 1e-10; + + let mut result = vec![false; n]; + for i in 0..n { + if a[i].is_nan() { + continue; + } + result[i] = match op { + CompareOp::Gt => a[i] > value, + CompareOp::Gte => a[i] >= value, + CompareOp::Lt => a[i] < value, + CompareOp::Lte => a[i] <= value, + CompareOp::Eq => (a[i] - value).abs() < tolerance, + CompareOp::Ne => (a[i] - value).abs() >= tolerance, + }; + } + + result +} + +/// Detect crossover/crossunder between two series. +/// +/// Crossover: a crosses above b (a[i-1] < b[i-1] and a[i] > b[i]) +/// Crossunder: a crosses below b (a[i-1] > b[i-1] and a[i] < b[i]) +/// +/// # Arguments +/// * `a` - First series +/// * `b` - Second series +/// * `cross_type` - Type of cross to detect +/// +/// # Returns +/// Boolean series indicating where cross occurs +pub fn cross(a: &[f64], b: &[f64], cross_type: CrossType) -> Vec { + let n = a.len(); + assert_eq!(n, b.len()); + + let mut result = vec![false; n]; + if n < 2 { + return result; + } + + for i in 1..n { + if a[i].is_nan() || b[i].is_nan() || a[i - 1].is_nan() || b[i - 1].is_nan() { + continue; + } + + result[i] = match cross_type { + CrossType::CrossOver => a[i - 1] <= b[i - 1] && a[i] > b[i], + CrossType::CrossUnder => a[i - 1] >= b[i - 1] && a[i] < b[i], + }; + } + + result +} + +/// Detect crossover with a scalar value. +/// +/// # Arguments +/// * `a` - Series +/// * `value` - Scalar value to cross +/// * `cross_type` - Type of cross to detect +/// +/// # Returns +/// Boolean series indicating where cross occurs +pub fn cross_scalar(a: &[f64], value: f64, cross_type: CrossType) -> Vec { + let n = a.len(); + let mut result = vec![false; n]; + + if n < 2 { + return result; + } + + for i in 1..n { + if a[i].is_nan() || a[i - 1].is_nan() { + continue; + } + + result[i] = match cross_type { + CrossType::CrossOver => a[i - 1] <= value && a[i] > value, + CrossType::CrossUnder => a[i - 1] >= value && a[i] < value, + }; + } + + result +} + +/// Check if value is in a range. +/// +/// # Arguments +/// * `a` - Series +/// * `low` - Lower bound +/// * `high` - Upper bound +/// +/// # Returns +/// Boolean series indicating where value is in range [low, high] +pub fn in_range(a: &[f64], low: f64, high: f64) -> Vec { + let n = a.len(); + let mut result = vec![false; n]; + + for i in 0..n { + if a[i].is_nan() { + continue; + } + result[i] = a[i] >= low && a[i] <= high; + } + + result +} + +/// Check if series is rising (current > previous). +/// +/// # Arguments +/// * `a` - Series +/// * `periods` - Number of periods to look back (default: 1) +/// +/// # Returns +/// Boolean series indicating where value is rising +pub fn is_rising(a: &[f64], periods: usize) -> Vec { + let n = a.len(); + let mut result = vec![false; n]; + + if periods >= n { + return result; + } + + for i in periods..n { + if a[i].is_nan() || a[i - periods].is_nan() { + continue; + } + result[i] = a[i] > a[i - periods]; + } + + result +} + +/// Check if series is falling (current < previous). +/// +/// # Arguments +/// * `a` - Series +/// * `periods` - Number of periods to look back (default: 1) +/// +/// # Returns +/// Boolean series indicating where value is falling +pub fn is_falling(a: &[f64], periods: usize) -> Vec { + let n = a.len(); + let mut result = vec![false; n]; + + if periods >= n { + return result; + } + + for i in periods..n { + if a[i].is_nan() || a[i - periods].is_nan() { + continue; + } + result[i] = a[i] < a[i - periods]; + } + + result +} + +/// Check if value has been above a threshold for n consecutive bars. +/// +/// # Arguments +/// * `a` - Series +/// * `threshold` - Threshold value +/// * `consecutive` - Number of consecutive bars required +/// +/// # Returns +/// Boolean series indicating where condition is met +pub fn above_for(a: &[f64], threshold: f64, consecutive: usize) -> Vec { + let n = a.len(); + let mut result = vec![false; n]; + + if consecutive > n { + return result; + } + + for i in (consecutive - 1)..n { + let mut all_above = true; + for j in 0..consecutive { + let idx = i - j; + if a[idx].is_nan() || a[idx] <= threshold { + all_above = false; + break; + } + } + result[i] = all_above; + } + + result +} + +/// Check if value has been below a threshold for n consecutive bars. +/// +/// # Arguments +/// * `a` - Series +/// * `threshold` - Threshold value +/// * `consecutive` - Number of consecutive bars required +/// +/// # Returns +/// Boolean series indicating where condition is met +pub fn below_for(a: &[f64], threshold: f64, consecutive: usize) -> Vec { + let n = a.len(); + let mut result = vec![false; n]; + + if consecutive > n { + return result; + } + + for i in (consecutive - 1)..n { + let mut all_below = true; + for j in 0..consecutive { + let idx = i - j; + if a[idx].is_nan() || a[idx] >= threshold { + all_below = false; + break; + } + } + result[i] = all_below; + } + + result +} + +/// Detect highest value in rolling window. +/// +/// # Arguments +/// * `a` - Series +/// * `window` - Window size +/// +/// # Returns +/// Boolean series indicating where current value is highest in window +pub fn is_highest(a: &[f64], window: usize) -> Vec { + let n = a.len(); + let mut result = vec![false; n]; + + if window > n || window == 0 { + return result; + } + + for i in (window - 1)..n { + let start = i + 1 - window; + let current = a[i]; + if current.is_nan() { + continue; + } + + let max_in_window = + a[start..=i].iter().filter(|v| !v.is_nan()).fold(f64::NEG_INFINITY, |a, &b| a.max(b)); + + result[i] = (current - max_in_window).abs() < 1e-10; + } + + result +} + +/// Detect lowest value in rolling window. +/// +/// # Arguments +/// * `a` - Series +/// * `window` - Window size +/// +/// # Returns +/// Boolean series indicating where current value is lowest in window +pub fn is_lowest(a: &[f64], window: usize) -> Vec { + let n = a.len(); + let mut result = vec![false; n]; + + if window > n || window == 0 { + return result; + } + + for i in (window - 1)..n { + let start = i + 1 - window; + let current = a[i]; + if current.is_nan() { + continue; + } + + let min_in_window = + a[start..=i].iter().filter(|v| !v.is_nan()).fold(f64::INFINITY, |a, &b| a.min(b)); + + result[i] = (current - min_in_window).abs() < 1e-10; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compare() { + let a = vec![1.0, 2.0, 3.0, 4.0]; + let b = vec![2.0, 2.0, 2.0, 2.0]; + + let result = compare(&a, &b, CompareOp::Gt); + assert!(!result[0]); // 1 > 2 = false + assert!(!result[1]); // 2 > 2 = false + assert!(result[2]); // 3 > 2 = true + assert!(result[3]); // 4 > 2 = true + } + + #[test] + fn test_crossover() { + let a = vec![1.0, 1.5, 2.5, 3.0, 2.5]; + let b = vec![2.0, 2.0, 2.0, 2.0, 2.0]; + + let result = cross(&a, &b, CrossType::CrossOver); + assert!(!result[0]); // No previous + assert!(!result[1]); // 1.0 < 2.0, 1.5 < 2.0 - still below + assert!(result[2]); // 1.5 < 2.0, 2.5 > 2.0 - crossed over! + assert!(!result[3]); // 2.5 > 2.0, 3.0 > 2.0 - already above + assert!(!result[4]); // 3.0 > 2.0, 2.5 > 2.0 - still above + } + + #[test] + fn test_crossunder() { + let a = vec![3.0, 2.5, 1.5, 1.0, 1.5]; + let b = vec![2.0, 2.0, 2.0, 2.0, 2.0]; + + let result = cross(&a, &b, CrossType::CrossUnder); + assert!(!result[0]); // No previous + assert!(!result[1]); // 3.0 > 2.0, 2.5 > 2.0 - still above + assert!(result[2]); // 2.5 > 2.0, 1.5 < 2.0 - crossed under! + assert!(!result[3]); // 1.5 < 2.0, 1.0 < 2.0 - already below + assert!(!result[4]); // 1.0 < 2.0, 1.5 < 2.0 - still below + } + + #[test] + fn test_in_range() { + let a = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + + let result = in_range(&a, 2.0, 4.0); + assert!(!result[0]); // 1 not in [2, 4] + assert!(result[1]); // 2 in [2, 4] + assert!(result[2]); // 3 in [2, 4] + assert!(result[3]); // 4 in [2, 4] + assert!(!result[4]); // 5 not in [2, 4] + } + + #[test] + fn test_is_rising() { + let a = vec![1.0, 2.0, 3.0, 2.5, 3.5]; + + let result = is_rising(&a, 1); + assert!(!result[0]); // No previous + assert!(result[1]); // 2 > 1 + assert!(result[2]); // 3 > 2 + assert!(!result[3]); // 2.5 < 3 + assert!(result[4]); // 3.5 > 2.5 + } + + #[test] + fn test_above_for() { + let a = vec![1.0, 3.0, 3.5, 4.0, 2.0, 3.0]; + let threshold = 2.5; + + let result = above_for(&a, threshold, 3); + assert!(!result[0]); + assert!(!result[1]); + assert!(!result[2]); // 1.0 < 2.5 + assert!(result[3]); // 3.0, 3.5, 4.0 all > 2.5 + assert!(!result[4]); // 2.0 < 2.5 + assert!(!result[5]); + } + + #[test] + fn test_is_highest() { + let a = vec![1.0, 3.0, 2.0, 4.0, 3.5]; + + let result = is_highest(&a, 3); + assert!(!result[0]); + assert!(!result[1]); + assert!(result[2] == false); // 2.0 is not highest in [1.0, 3.0, 2.0] + assert!(result[3]); // 4.0 is highest in [3.0, 2.0, 4.0] + assert!(!result[4]); // 3.5 is not highest in [2.0, 4.0, 3.5] + } +} diff --git a/src/signals/mod.rs b/src/signals/mod.rs new file mode 100644 index 0000000..fb7b401 --- /dev/null +++ b/src/signals/mod.rs @@ -0,0 +1,12 @@ +//! Signal processing for RaptorBT. +//! +//! This module handles signal cleaning, synchronization, and expression evaluation. + +pub mod expression; +pub mod processor; +pub mod synchronizer; +pub mod tick_signals; + +pub use processor::SignalProcessor; +pub use synchronizer::{SignalSynchronizer, SyncMode}; +pub use tick_signals::{tick_momentum_entry, tick_momentum_exit}; diff --git a/src/signals/processor.rs b/src/signals/processor.rs new file mode 100644 index 0000000..046f128 --- /dev/null +++ b/src/signals/processor.rs @@ -0,0 +1,427 @@ +//! Signal processor for cleaning entry/exit signals. +//! +//! Ensures proper alternation between entries and exits to prevent +//! overlapping positions or orphaned signals. + +use crate::core::types::Direction; + +/// Signal processor for cleaning raw entry/exit signals. +#[derive(Debug, Clone)] +pub struct SignalProcessor { + /// Whether to allow multiple entries before an exit (pyramiding). + pub allow_pyramiding: bool, + /// Maximum number of pyramid entries. + pub max_pyramid_entries: usize, +} + +impl Default for SignalProcessor { + fn default() -> Self { + Self { allow_pyramiding: false, max_pyramid_entries: 1 } + } +} + +impl SignalProcessor { + /// Create a new signal processor. + pub fn new() -> Self { + Self::default() + } + + /// Enable pyramiding with a maximum number of entries. + pub fn with_pyramiding(mut self, max_entries: usize) -> Self { + self.allow_pyramiding = max_entries > 1; + self.max_pyramid_entries = max_entries; + self + } + + /// Clean entry/exit signals to ensure proper alternation. + /// + /// Rules: + /// 1. First signal must be an entry + /// 2. After an entry, ignore further entries (unless pyramiding) + /// 3. After an exit, ignore further exits + /// 4. Entries and exits must alternate properly + /// 5. Same-bar conflict: If both entry AND exit signals are True on the same bar + /// when in position, entry takes priority — stay in position (ignore the exit). + /// + /// # Arguments + /// * `entries` - Raw entry signals + /// * `exits` - Raw exit signals + /// + /// # Returns + /// Tuple of (cleaned_entries, cleaned_exits) + pub fn clean_signals(&self, entries: &[bool], exits: &[bool]) -> (Vec, Vec) { + let n = entries.len(); + assert_eq!(n, exits.len(), "Entry and exit arrays must have same length"); + + let mut clean_entries = vec![false; n]; + let mut clean_exits = vec![false; n]; + + if n == 0 { + return (clean_entries, clean_exits); + } + + let mut in_position = false; + let mut position_count = 0; + + for i in 0..n { + if !in_position { + // Not in position - looking for entry + if entries[i] { + clean_entries[i] = true; + in_position = true; + position_count = 1; + } + // Ignore exits when not in position + } else { + // In position - looking for exit (or pyramid entry) + // Same-bar conflict: entry takes priority — stay in position + if exits[i] && !entries[i] { + // Only exit if there's no conflicting entry signal + clean_exits[i] = true; + if self.allow_pyramiding { + position_count -= 1; + if position_count == 0 { + in_position = false; + } + } else { + in_position = false; + position_count = 0; + } + } else if entries[i] + && self.allow_pyramiding + && position_count < self.max_pyramid_entries + { + // Pyramid entry + clean_entries[i] = true; + position_count += 1; + } + // If both entry and exit are True, we stay in position (ignore both) + // If only entry is True and not pyramiding, ignore entry (already in position) + } + } + + (clean_entries, clean_exits) + } + + /// Clean signals with direction awareness (for strategies that can go long/short). + /// + /// # Arguments + /// * `long_entries` - Long entry signals + /// * `long_exits` - Long exit signals + /// * `short_entries` - Short entry signals + /// * `short_exits` - Short exit signals + /// + /// # Returns + /// Tuple of (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits) + pub fn clean_signals_bidirectional( + &self, + long_entries: &[bool], + long_exits: &[bool], + short_entries: &[bool], + short_exits: &[bool], + ) -> (Vec, Vec, Vec, Vec) { + let n = long_entries.len(); + assert_eq!(n, long_exits.len()); + assert_eq!(n, short_entries.len()); + assert_eq!(n, short_exits.len()); + + let mut clean_long_entries = vec![false; n]; + let mut clean_long_exits = vec![false; n]; + let mut clean_short_entries = vec![false; n]; + let mut clean_short_exits = vec![false; n]; + + if n == 0 { + return (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits); + } + + let mut current_direction: Option = None; + + for i in 0..n { + match current_direction { + None => { + // Not in any position - look for entry + if long_entries[i] { + clean_long_entries[i] = true; + current_direction = Some(Direction::Long); + } else if short_entries[i] { + clean_short_entries[i] = true; + current_direction = Some(Direction::Short); + } + } + Some(Direction::Long) => { + // In long position - look for exit or reversal + if long_exits[i] { + clean_long_exits[i] = true; + current_direction = None; + } else if short_entries[i] { + // Reversal: exit long and enter short + clean_long_exits[i] = true; + clean_short_entries[i] = true; + current_direction = Some(Direction::Short); + } + } + Some(Direction::Short) => { + // In short position - look for exit or reversal + if short_exits[i] { + clean_short_exits[i] = true; + current_direction = None; + } else if long_entries[i] { + // Reversal: exit short and enter long + clean_short_exits[i] = true; + clean_long_entries[i] = true; + current_direction = Some(Direction::Long); + } + } + } + } + + (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits) + } + + /// Generate exit-on-opposite-entry signals. + /// + /// Useful for strategies where an entry in opposite direction + /// should automatically close the current position. + /// + /// # Arguments + /// * `entries` - Entry signals + /// * `direction` - Current position direction + /// + /// # Returns + /// Modified exit signals that include opposite-direction entries as exits + pub fn exits_from_opposite_entries( + &self, + long_entries: &[bool], + short_entries: &[bool], + ) -> (Vec, Vec) { + let n = long_entries.len(); + assert_eq!(n, short_entries.len()); + + // Long exits when short entry + // Short exits when long entry + (short_entries.to_vec(), long_entries.to_vec()) + } + + /// Count the number of trades that would be generated from signals. + /// + /// # Arguments + /// * `entries` - Entry signals (already cleaned) + /// * `exits` - Exit signals (already cleaned) + /// + /// # Returns + /// Number of complete trades (entry + exit pairs) + pub fn count_trades(_entries: &[bool], exits: &[bool]) -> usize { + exits.iter().filter(|&&e| e).count() + } + + /// Get indices of entries and exits. + /// + /// # Arguments + /// * `entries` - Entry signals + /// * `exits` - Exit signals + /// + /// # Returns + /// Tuple of (entry_indices, exit_indices) + pub fn get_trade_indices(entries: &[bool], exits: &[bool]) -> (Vec, Vec) { + let entry_indices: Vec = entries + .iter() + .enumerate() + .filter_map(|(i, &e)| if e { Some(i) } else { None }) + .collect(); + + let exit_indices: Vec = + exits.iter().enumerate().filter_map(|(i, &e)| if e { Some(i) } else { None }).collect(); + + (entry_indices, exit_indices) + } +} + +/// Shift signals forward by n bars (delays execution). +pub fn shift_signals(signals: &[bool], n: usize) -> Vec { + let len = signals.len(); + let mut result = vec![false; len]; + + if n >= len { + return result; + } + + for i in n..len { + result[i] = signals[i - n]; + } + + result +} + +/// Combine multiple signal arrays with AND logic. +pub fn combine_signals_and(signals: &[&[bool]]) -> Vec { + if signals.is_empty() { + return vec![]; + } + + let n = signals[0].len(); + for sig in signals.iter() { + assert_eq!(sig.len(), n, "All signal arrays must have same length"); + } + + let mut result = vec![true; n]; + for sig in signals.iter() { + for i in 0..n { + result[i] = result[i] && sig[i]; + } + } + + result +} + +/// Combine multiple signal arrays with OR logic. +pub fn combine_signals_or(signals: &[&[bool]]) -> Vec { + if signals.is_empty() { + return vec![]; + } + + let n = signals[0].len(); + for sig in signals.iter() { + assert_eq!(sig.len(), n, "All signal arrays must have same length"); + } + + let mut result = vec![false; n]; + for sig in signals.iter() { + for i in 0..n { + result[i] = result[i] || sig[i]; + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clean_signals_basic() { + let processor = SignalProcessor::new(); + + let entries = vec![true, false, true, false, true, false]; + let exits = vec![false, true, false, true, false, true]; + + let (clean_e, clean_x) = processor.clean_signals(&entries, &exits); + + // First entry should be kept + assert!(clean_e[0]); + // First exit should be kept + assert!(clean_x[1]); + // Second entry should be kept + assert!(clean_e[2]); + // Second exit should be kept + assert!(clean_x[3]); + } + + #[test] + fn test_clean_signals_consecutive_entries() { + let processor = SignalProcessor::new(); + + let entries = vec![true, true, true, false, false]; + let exits = vec![false, false, false, true, false]; + + let (clean_e, clean_x) = processor.clean_signals(&entries, &exits); + + // Only first entry should be kept + assert!(clean_e[0]); + assert!(!clean_e[1]); + assert!(!clean_e[2]); + // Exit should be kept + assert!(clean_x[3]); + } + + #[test] + fn test_clean_signals_consecutive_exits() { + let processor = SignalProcessor::new(); + + let entries = vec![true, false, false, false, false]; + let exits = vec![false, true, true, true, false]; + + let (clean_e, clean_x) = processor.clean_signals(&entries, &exits); + + // Entry should be kept + assert!(clean_e[0]); + // Only first exit should be kept + assert!(clean_x[1]); + assert!(!clean_x[2]); + assert!(!clean_x[3]); + } + + #[test] + fn test_clean_signals_exit_before_entry() { + let processor = SignalProcessor::new(); + + let entries = vec![false, false, true, false, false]; + let exits = vec![true, true, false, true, false]; + + let (clean_e, clean_x) = processor.clean_signals(&entries, &exits); + + // Exits before first entry should be ignored + assert!(!clean_x[0]); + assert!(!clean_x[1]); + // Entry should be kept + assert!(clean_e[2]); + // Exit after entry should be kept + assert!(clean_x[3]); + } + + #[test] + fn test_pyramiding() { + let processor = SignalProcessor::new().with_pyramiding(3); + + let entries = vec![true, true, true, false, false]; + let exits = vec![false, false, false, true, true]; + + let (clean_e, clean_x) = processor.clean_signals(&entries, &exits); + + // All three entries should be kept (pyramiding) + assert!(clean_e[0]); + assert!(clean_e[1]); + assert!(clean_e[2]); + // Both exits should be kept + assert!(clean_x[3]); + assert!(clean_x[4]); + } + + #[test] + fn test_shift_signals() { + let signals = vec![true, false, true, false, true]; + let shifted = shift_signals(&signals, 2); + + assert!(!shifted[0]); + assert!(!shifted[1]); + assert!(shifted[2]); // Original [0] + assert!(!shifted[3]); // Original [1] + assert!(shifted[4]); // Original [2] + } + + #[test] + fn test_combine_signals_and() { + let sig1 = vec![true, true, false, false]; + let sig2 = vec![true, false, true, false]; + + let combined = combine_signals_and(&[&sig1, &sig2]); + + assert!(combined[0]); // true && true + assert!(!combined[1]); // true && false + assert!(!combined[2]); // false && true + assert!(!combined[3]); // false && false + } + + #[test] + fn test_combine_signals_or() { + let sig1 = vec![true, true, false, false]; + let sig2 = vec![true, false, true, false]; + + let combined = combine_signals_or(&[&sig1, &sig2]); + + assert!(combined[0]); // true || true + assert!(combined[1]); // true || false + assert!(combined[2]); // false || true + assert!(!combined[3]); // false || false + } +} diff --git a/src/signals/synchronizer.rs b/src/signals/synchronizer.rs new file mode 100644 index 0000000..dced71c --- /dev/null +++ b/src/signals/synchronizer.rs @@ -0,0 +1,385 @@ +//! Signal synchronization for multi-instrument strategies. +//! +//! Handles combining signals from multiple instruments with different sync modes. + +use crate::core::types::CompiledSignals; + +/// Synchronization mode for combining signals from multiple instruments. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncMode { + /// All instruments must signal (AND logic). + All, + /// Any instrument can signal (OR logic). + Any, + /// Majority of instruments must signal. + Majority, + /// Use first instrument's signals as master. + Master, +} + +impl Default for SyncMode { + fn default() -> Self { + SyncMode::All + } +} + +/// Signal synchronizer for multi-instrument backtests. +#[derive(Debug, Clone)] +pub struct SignalSynchronizer { + /// Synchronization mode. + pub mode: SyncMode, + /// Minimum number of instruments that must signal (for custom thresholds). + pub min_signals: Option, +} + +impl Default for SignalSynchronizer { + fn default() -> Self { + Self { mode: SyncMode::All, min_signals: None } + } +} + +impl SignalSynchronizer { + /// Create a new signal synchronizer with the given mode. + pub fn new(mode: SyncMode) -> Self { + Self { mode, min_signals: None } + } + + /// Create a synchronizer with a custom minimum signal threshold. + pub fn with_min_signals(min: usize) -> Self { + Self { mode: SyncMode::Majority, min_signals: Some(min) } + } + + /// Synchronize entry signals from multiple instruments. + /// + /// # Arguments + /// * `signals` - Slice of signal arrays from each instrument + /// + /// # Returns + /// Combined entry signals based on sync mode + pub fn sync_entries(&self, signals: &[&[bool]]) -> Vec { + if signals.is_empty() { + return vec![]; + } + + let n = signals[0].len(); + for sig in signals.iter() { + assert_eq!(sig.len(), n, "All signal arrays must have same length"); + } + + let num_instruments = signals.len(); + let mut result = vec![false; n]; + + for i in 0..n { + let count = signals.iter().filter(|s| s[i]).count(); + + result[i] = match self.mode { + SyncMode::All => count == num_instruments, + SyncMode::Any => count > 0, + SyncMode::Majority => { + let threshold = self.min_signals.unwrap_or((num_instruments + 1) / 2); + count >= threshold + } + SyncMode::Master => signals[0][i], + }; + } + + result + } + + /// Synchronize exit signals from multiple instruments. + /// + /// Exit logic is typically inverse of entry: + /// - All mode -> exit on Any + /// - Any mode -> exit on All + /// - Majority mode -> exit when majority want to exit + /// - Master mode -> use master's exit signals + /// + /// # Arguments + /// * `signals` - Slice of signal arrays from each instrument + /// + /// # Returns + /// Combined exit signals based on sync mode + pub fn sync_exits(&self, signals: &[&[bool]]) -> Vec { + if signals.is_empty() { + return vec![]; + } + + let n = signals[0].len(); + for sig in signals.iter() { + assert_eq!(sig.len(), n, "All signal arrays must have same length"); + } + + let num_instruments = signals.len(); + let mut result = vec![false; n]; + + for i in 0..n { + let count = signals.iter().filter(|s| s[i]).count(); + + result[i] = match self.mode { + // For All entry mode, exit when ANY wants to exit + SyncMode::All => count > 0, + // For Any entry mode, exit when ALL want to exit + SyncMode::Any => count == num_instruments, + SyncMode::Majority => { + let threshold = self.min_signals.unwrap_or((num_instruments + 1) / 2); + count >= threshold + } + SyncMode::Master => signals[0][i], + }; + } + + result + } + + /// Synchronize signals from CompiledSignals objects. + /// + /// # Arguments + /// * `compiled_signals` - Slice of CompiledSignals from each instrument + /// + /// # Returns + /// Tuple of (synchronized_entries, synchronized_exits) + pub fn sync_compiled_signals( + &self, + compiled_signals: &[&CompiledSignals], + ) -> (Vec, Vec) { + if compiled_signals.is_empty() { + return (vec![], vec![]); + } + + let entries: Vec<&[bool]> = + compiled_signals.iter().map(|cs| cs.entries.as_slice()).collect(); + + let exits: Vec<&[bool]> = compiled_signals.iter().map(|cs| cs.exits.as_slice()).collect(); + + let synced_entries = self.sync_entries(&entries); + let synced_exits = self.sync_exits(&exits); + + (synced_entries, synced_exits) + } + + /// Calculate signal agreement score (0.0 to 1.0). + /// + /// # Arguments + /// * `signals` - Slice of signal arrays from each instrument + /// + /// # Returns + /// Vector of agreement scores for each bar + pub fn signal_agreement(&self, signals: &[&[bool]]) -> Vec { + if signals.is_empty() { + return vec![]; + } + + let n = signals[0].len(); + let num_instruments = signals.len() as f64; + + let mut result = vec![0.0; n]; + + for i in 0..n { + let count = signals.iter().filter(|s| s[i]).count() as f64; + result[i] = count / num_instruments; + } + + result + } +} + +/// Align signals to a common time axis. +/// +/// Useful when instruments have different trading hours or missing data. +/// +/// # Arguments +/// * `signals` - Signal array to align +/// * `source_timestamps` - Timestamps of the signal array +/// * `target_timestamps` - Target timestamp grid +/// * `fill_value` - Value to use for missing timestamps +/// +/// # Returns +/// Aligned signal array +pub fn align_signals( + signals: &[bool], + source_timestamps: &[i64], + target_timestamps: &[i64], + fill_value: bool, +) -> Vec { + let n = target_timestamps.len(); + let mut result = vec![fill_value; n]; + + // Create a map of source timestamps to indices + let mut source_map = std::collections::HashMap::new(); + for (i, &ts) in source_timestamps.iter().enumerate() { + source_map.insert(ts, i); + } + + // Fill in values where timestamps match + for (i, &ts) in target_timestamps.iter().enumerate() { + if let Some(&source_idx) = source_map.get(&ts) { + result[i] = signals[source_idx]; + } + } + + result +} + +/// Forward-fill signals (carry forward last signal). +pub fn forward_fill_signals(signals: &[bool]) -> Vec { + let mut result = signals.to_vec(); + let mut last_value = false; + + for i in 0..result.len() { + if result[i] { + last_value = true; + } + result[i] = last_value; + } + + result +} + +/// Create synchronized position signals. +/// +/// Returns a position signal where: +/// - 1 = in position +/// - 0 = out of position +/// +/// # Arguments +/// * `entries` - Entry signals (cleaned) +/// * `exits` - Exit signals (cleaned) +/// +/// # Returns +/// Position state array +pub fn position_signals(entries: &[bool], exits: &[bool]) -> Vec { + let n = entries.len(); + assert_eq!(n, exits.len()); + + let mut result = vec![0i8; n]; + let mut in_position = false; + + for i in 0..n { + if entries[i] { + in_position = true; + } + if exits[i] { + in_position = false; + } + result[i] = if in_position { 1 } else { 0 }; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sync_all() { + let sync = SignalSynchronizer::new(SyncMode::All); + + let sig1 = vec![true, true, false, true]; + let sig2 = vec![true, false, false, true]; + let sig3 = vec![true, true, false, true]; + + let result = sync.sync_entries(&[&sig1, &sig2, &sig3]); + + assert!(result[0]); // All true + assert!(!result[1]); // Not all true + assert!(!result[2]); // All false + assert!(result[3]); // All true + } + + #[test] + fn test_sync_any() { + let sync = SignalSynchronizer::new(SyncMode::Any); + + let sig1 = vec![true, false, false, false]; + let sig2 = vec![false, true, false, false]; + let sig3 = vec![false, false, false, false]; + + let result = sync.sync_entries(&[&sig1, &sig2, &sig3]); + + assert!(result[0]); // At least one true + assert!(result[1]); // At least one true + assert!(!result[2]); // All false + assert!(!result[3]); // All false + } + + #[test] + fn test_sync_majority() { + let sync = SignalSynchronizer::new(SyncMode::Majority); + + let sig1 = vec![true, true, false, true]; + let sig2 = vec![true, false, false, true]; + let sig3 = vec![false, true, false, false]; + + let result = sync.sync_entries(&[&sig1, &sig2, &sig3]); + + assert!(result[0]); // 2 out of 3 + assert!(result[1]); // 2 out of 3 + assert!(!result[2]); // 0 out of 3 + assert!(result[3]); // 2 out of 3 + } + + #[test] + fn test_sync_master() { + let sync = SignalSynchronizer::new(SyncMode::Master); + + let sig1 = vec![true, false, true, false]; // Master + let sig2 = vec![false, true, false, true]; + let sig3 = vec![true, true, true, true]; + + let result = sync.sync_entries(&[&sig1, &sig2, &sig3]); + + // Should follow master (sig1) + assert!(result[0]); + assert!(!result[1]); + assert!(result[2]); + assert!(!result[3]); + } + + #[test] + fn test_exit_inverse_logic() { + // For All entry mode, exit should be Any + let sync = SignalSynchronizer::new(SyncMode::All); + + let exit1 = vec![true, false, false]; + let exit2 = vec![false, false, false]; + let exit3 = vec![false, false, false]; + + let result = sync.sync_exits(&[&exit1, &exit2, &exit3]); + + assert!(result[0]); // Any true -> exit + assert!(!result[1]); + assert!(!result[2]); + } + + #[test] + fn test_signal_agreement() { + let sync = SignalSynchronizer::new(SyncMode::All); + + let sig1 = vec![true, true, false, true]; + let sig2 = vec![true, false, false, true]; + let sig3 = vec![false, true, false, true]; + + let result = sync.signal_agreement(&[&sig1, &sig2, &sig3]); + + assert!((result[0] - 2.0 / 3.0).abs() < 1e-10); + assert!((result[1] - 2.0 / 3.0).abs() < 1e-10); + assert!((result[2] - 0.0).abs() < 1e-10); + assert!((result[3] - 1.0).abs() < 1e-10); + } + + #[test] + fn test_position_signals() { + let entries = vec![false, true, false, false, true, false]; + let exits = vec![false, false, false, true, false, true]; + + let result = position_signals(&entries, &exits); + + assert_eq!(result[0], 0); + assert_eq!(result[1], 1); + assert_eq!(result[2], 1); + assert_eq!(result[3], 0); + assert_eq!(result[4], 1); + assert_eq!(result[5], 0); + } +} diff --git a/src/signals/tick_signals.rs b/src/signals/tick_signals.rs new file mode 100644 index 0000000..5e57931 --- /dev/null +++ b/src/signals/tick_signals.rs @@ -0,0 +1,174 @@ +//! Tick-level signal generation for momentum entry/exit. +//! +//! Converts precomputed feature arrays (one scalar per tick) into entry and +//! exit boolean arrays that can be fed directly into `run_tick_backtest`. +//! +//! All functions are O(N) single-pass — no backward linear search, no nested +//! loops. The return_1m feature array must be precomputed by the caller +//! (via `tick_features::return_window` or equivalent). + +/// Generate momentum entry signals from per-tick feature arrays. +/// +/// All input slices must have the same length N. +/// +/// Rules applied in order (a failing rule sets entry[i] = false): +/// 1. spread gate: `spread_pct[i] <= spread_pct_max` +/// 2. BSI gate: if `bsi_min > 0.0`, `bsi_delta[i] >= bsi_min` +/// 3. return gate: if `return_1m_min_abs > 0.0`, direction-aligned +/// `return_1m[i]` must have `abs >= return_1m_min_abs` and correct sign. +/// NaN return_1m always fails the gate. +/// 4. cooldown: after each entry, suppress the next `cooldown_ticks` ticks. +/// +/// `return_direction`: +1 for long (return_1m must be positive), -1 for short +/// (return_1m must be negative). +pub fn tick_momentum_entry( + spread_pct: &[f64], + bsi_delta: &[f64], + return_1m: &[f64], + spread_pct_max: f64, + bsi_min: f64, + return_1m_min_abs: f64, + return_direction: i8, + cooldown_ticks: usize, +) -> Vec { + let n = spread_pct.len(); + let mut entries = vec![false; n]; + let mut cooldown_until: usize = 0; + + for i in 0..n { + if i < cooldown_until { + continue; + } + + // Spread gate + if spread_pct[i] > spread_pct_max { + continue; + } + + // BSI delta gate (disabled when bsi_min == 0.0) + if bsi_min > 0.0 { + let b = if i < bsi_delta.len() { bsi_delta[i] } else { continue }; + if b < bsi_min { + continue; + } + } + + // 1-minute return gate (disabled when return_1m_min_abs == 0.0) + if return_1m_min_abs > 0.0 { + let r = if i < return_1m.len() { return_1m[i] } else { continue }; + if r.is_nan() { + continue; + } + let abs_r = r.abs(); + if abs_r < return_1m_min_abs { + continue; + } + // Direction alignment: long needs positive return, short needs negative + if return_direction > 0 && r < 0.0 { + continue; + } + if return_direction < 0 && r > 0.0 { + continue; + } + } + + entries[i] = true; + cooldown_until = i + 1 + cooldown_ticks; + } + + entries +} + +/// Generate time-based exit signals (EOD / session-end). +/// +/// Sets exit[i] = true for every tick at or after `eod_exit_time_ns`. +/// When `eod_exit_time_ns == 0` all exits are false (disabled). +/// +/// `timestamps_ns`: nanoseconds-since-epoch timestamp for each tick. +pub fn tick_momentum_exit(timestamps_ns: &[i64], eod_exit_time_ns: i64) -> Vec { + let n = timestamps_ns.len(); + if eod_exit_time_ns == 0 { + return vec![false; n]; + } + timestamps_ns + .iter() + .map(|&ts| ts >= eod_exit_time_ns) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_return_1m(vals: &[f64]) -> Vec { + vals.to_vec() + } + + #[test] + fn test_entry_spread_gate() { + // All spreads above max → no entries + let spread = vec![3.0, 4.0, 6.0]; + let bsi = vec![0.6, 0.7, 0.8]; + let ret = vec![1.0, 1.0, 1.0]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 2.0, 0.0, 0.0, 1, 0); + assert_eq!(entries, vec![false, false, false]); + } + + #[test] + fn test_entry_bsi_gate() { + let spread = vec![1.0, 1.0, 1.0]; + let bsi = vec![0.3, 0.6, 0.4]; // only index 1 passes bsi_min=0.5 + let ret = vec![0.5, 0.5, 0.5]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.5, 0.0, 1, 0); + assert_eq!(entries, vec![false, true, false]); + } + + #[test] + fn test_entry_return_gate_long() { + let spread = vec![1.0, 1.0, 1.0, 1.0]; + let bsi = vec![0.6, 0.6, 0.6, 0.6]; + // positive, positive, too small, negative + let ret = vec![0.5, 1.0, 0.1, -0.5]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, 1, 0); + assert_eq!(entries, vec![true, true, false, false]); + } + + #[test] + fn test_entry_return_gate_short() { + let spread = vec![1.0, 1.0, 1.0]; + let bsi = vec![0.6, 0.6, 0.6]; + // negative enough, positive (fails direction), nan + let ret = vec![-0.5, 0.5, f64::NAN]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, -1, 0); + assert_eq!(entries, vec![true, false, false]); + } + + #[test] + fn test_entry_cooldown() { + // cooldown_ticks=2: after entry at i=0, next eligible at i=3 + let spread = vec![1.0; 6]; + let bsi = vec![0.6; 6]; + let ret = vec![0.0; 6]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.0, 1, 2); + assert!(entries[0]); + assert!(!entries[1]); + assert!(!entries[2]); + assert!(entries[3]); + assert!(!entries[4]); + assert!(!entries[5]); + } + + #[test] + fn test_exit_disabled() { + let ts = vec![1_000_000_i64, 2_000_000, 3_000_000]; + let exits = tick_momentum_exit(&ts, 0); + assert_eq!(exits, vec![false, false, false]); + } + + #[test] + fn test_exit_eod_fires() { + let ts = vec![1_000_i64, 2_000, 3_000, 4_000]; + let exits = tick_momentum_exit(&ts, 3_000); + assert_eq!(exits, vec![false, false, true, true]); + } +} diff --git a/src/stops/atr.rs b/src/stops/atr.rs new file mode 100644 index 0000000..3ab3b21 --- /dev/null +++ b/src/stops/atr.rs @@ -0,0 +1,237 @@ +//! ATR-based stop-loss and take-profit. + +use super::{StopCalculator, TargetCalculator}; +use crate::core::types::{Direction, Price}; + +/// ATR-based stop-loss. +#[derive(Debug, Clone)] +pub struct AtrStop { + /// ATR multiplier. + pub multiplier: f64, + /// Current ATR value. + pub atr: f64, +} + +impl AtrStop { + /// Create a new ATR stop. + pub fn new(multiplier: f64, atr: f64) -> Self { + Self { multiplier, atr } + } + + /// Update ATR value. + pub fn update_atr(&mut self, atr: f64) { + self.atr = atr; + } +} + +impl StopCalculator for AtrStop { + fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option { + if self.atr <= 0.0 { + return None; + } + + let distance = self.atr * self.multiplier; + let stop = match direction { + Direction::Long => entry_price - distance, + Direction::Short => entry_price + distance, + }; + Some(stop) + } + + fn update_stop( + &self, + current_stop: Option, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // ATR stop doesn't trail by default + current_stop + } +} + +/// ATR-based take-profit. +#[derive(Debug, Clone)] +pub struct AtrTarget { + /// ATR multiplier. + pub multiplier: f64, + /// Current ATR value. + pub atr: f64, +} + +impl AtrTarget { + /// Create a new ATR target. + pub fn new(multiplier: f64, atr: f64) -> Self { + Self { multiplier, atr } + } + + /// Update ATR value. + pub fn update_atr(&mut self, atr: f64) { + self.atr = atr; + } +} + +impl TargetCalculator for AtrTarget { + fn calculate_target( + &self, + entry_price: Price, + _stop_price: Option, + direction: Direction, + ) -> Option { + if self.atr <= 0.0 { + return None; + } + + let distance = self.atr * self.multiplier; + let target = match direction { + Direction::Long => entry_price + distance, + Direction::Short => entry_price - distance, + }; + Some(target) + } +} + +/// Chandelier exit (ATR-based trailing stop from high/low). +#[derive(Debug, Clone)] +pub struct ChandelierExit { + /// ATR multiplier. + pub multiplier: f64, + /// Current ATR value. + pub atr: f64, + /// Highest high since entry (for long). + pub highest_high: f64, + /// Lowest low since entry (for short). + pub lowest_low: f64, +} + +impl ChandelierExit { + /// Create a new Chandelier exit. + pub fn new(multiplier: f64, atr: f64) -> Self { + Self { multiplier, atr, highest_high: 0.0, lowest_low: f64::MAX } + } + + /// Reset for new position. + pub fn reset(&mut self, entry_price: Price) { + self.highest_high = entry_price; + self.lowest_low = entry_price; + } + + /// Update with new bar data. + pub fn update(&mut self, high: Price, low: Price, atr: f64) { + if high > self.highest_high { + self.highest_high = high; + } + if low < self.lowest_low { + self.lowest_low = low; + } + self.atr = atr; + } + + /// Get current stop level. + pub fn stop_level(&self, direction: Direction) -> Option { + if self.atr <= 0.0 { + return None; + } + + let distance = self.atr * self.multiplier; + let stop = match direction { + Direction::Long => self.highest_high - distance, + Direction::Short => self.lowest_low + distance, + }; + Some(stop) + } +} + +impl StopCalculator for ChandelierExit { + fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option { + if self.atr <= 0.0 { + return None; + } + + let distance = self.atr * self.multiplier; + let stop = match direction { + Direction::Long => entry_price - distance, + Direction::Short => entry_price + distance, + }; + Some(stop) + } + + fn update_stop( + &self, + current_stop: Option, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + if self.atr <= 0.0 { + return current_stop; + } + + let distance = self.atr * self.multiplier; + let new_stop = match direction { + Direction::Long => { + let proposed = high - distance; + current_stop.map(|cs| cs.max(proposed)).or(Some(proposed)) + } + Direction::Short => { + let proposed = low + distance; + current_stop.map(|cs| cs.min(proposed)).or(Some(proposed)) + } + }; + + new_stop + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_atr_stop_long() { + let stop = AtrStop::new(2.0, 5.0); + let result = stop.calculate_stop(100.0, Direction::Long); + // 100 - (2 * 5) = 90 + assert!((result.unwrap() - 90.0).abs() < 1e-10); + } + + #[test] + fn test_atr_stop_short() { + let stop = AtrStop::new(2.0, 5.0); + let result = stop.calculate_stop(100.0, Direction::Short); + // 100 + (2 * 5) = 110 + assert!((result.unwrap() - 110.0).abs() < 1e-10); + } + + #[test] + fn test_atr_target() { + let target = AtrTarget::new(3.0, 5.0); + let result = target.calculate_target(100.0, None, Direction::Long); + // 100 + (3 * 5) = 115 + assert!((result.unwrap() - 115.0).abs() < 1e-10); + } + + #[test] + fn test_chandelier_exit() { + let mut chandelier = ChandelierExit::new(3.0, 2.0); + chandelier.reset(100.0); + + // Simulate price movement up + chandelier.update(105.0, 99.0, 2.0); + chandelier.update(110.0, 103.0, 2.0); + + // Long stop should trail from highest high + // 110 - (3 * 2) = 104 + let stop = chandelier.stop_level(Direction::Long); + assert!((stop.unwrap() - 104.0).abs() < 1e-10); + } + + #[test] + fn test_atr_zero() { + let stop = AtrStop::new(2.0, 0.0); + let result = stop.calculate_stop(100.0, Direction::Long); + assert!(result.is_none()); + } +} diff --git a/src/stops/fixed.rs b/src/stops/fixed.rs new file mode 100644 index 0000000..b2dface --- /dev/null +++ b/src/stops/fixed.rs @@ -0,0 +1,168 @@ +//! Fixed percentage stop-loss and take-profit. + +use super::{StopCalculator, TargetCalculator}; +use crate::core::types::{Direction, Price}; + +/// Fixed percentage stop-loss. +#[derive(Debug, Clone, Copy)] +pub struct FixedStop { + /// Stop percentage (e.g., 0.02 for 2%). + pub percent: f64, +} + +impl FixedStop { + /// Create a new fixed stop with given percentage. + pub fn new(percent: f64) -> Self { + Self { percent: percent.abs() } + } + + /// Create a 1% stop. + pub fn one_percent() -> Self { + Self::new(0.01) + } + + /// Create a 2% stop. + pub fn two_percent() -> Self { + Self::new(0.02) + } + + /// Create a 5% stop. + pub fn five_percent() -> Self { + Self::new(0.05) + } +} + +impl StopCalculator for FixedStop { + fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option { + let stop = match direction { + Direction::Long => entry_price * (1.0 - self.percent), + Direction::Short => entry_price * (1.0 + self.percent), + }; + Some(stop) + } + + fn update_stop( + &self, + current_stop: Option, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // Fixed stop doesn't update + current_stop + } +} + +/// Fixed percentage take-profit. +#[derive(Debug, Clone, Copy)] +pub struct FixedTarget { + /// Target percentage (e.g., 0.04 for 4%). + pub percent: f64, +} + +impl FixedTarget { + /// Create a new fixed target with given percentage. + pub fn new(percent: f64) -> Self { + Self { percent: percent.abs() } + } +} + +impl TargetCalculator for FixedTarget { + fn calculate_target( + &self, + entry_price: Price, + _stop_price: Option, + direction: Direction, + ) -> Option { + let target = match direction { + Direction::Long => entry_price * (1.0 + self.percent), + Direction::Short => entry_price * (1.0 - self.percent), + }; + Some(target) + } +} + +/// Risk-reward based take-profit. +#[derive(Debug, Clone, Copy)] +pub struct RiskRewardTarget { + /// Risk-reward ratio (e.g., 2.0 for 2:1 reward:risk). + pub ratio: f64, +} + +impl RiskRewardTarget { + /// Create a new risk-reward target. + pub fn new(ratio: f64) -> Self { + Self { ratio } + } + + /// Create a 2:1 target. + pub fn two_to_one() -> Self { + Self::new(2.0) + } + + /// Create a 3:1 target. + pub fn three_to_one() -> Self { + Self::new(3.0) + } +} + +impl TargetCalculator for RiskRewardTarget { + fn calculate_target( + &self, + entry_price: Price, + stop_price: Option, + direction: Direction, + ) -> Option { + let stop = stop_price?; + let risk = (entry_price - stop).abs(); + let reward = risk * self.ratio; + + let target = match direction { + Direction::Long => entry_price + reward, + Direction::Short => entry_price - reward, + }; + Some(target) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fixed_stop_long() { + let stop = FixedStop::new(0.02); + let result = stop.calculate_stop(100.0, Direction::Long); + assert!((result.unwrap() - 98.0).abs() < 1e-10); + } + + #[test] + fn test_fixed_stop_short() { + let stop = FixedStop::new(0.02); + let result = stop.calculate_stop(100.0, Direction::Short); + assert!((result.unwrap() - 102.0).abs() < 1e-10); + } + + #[test] + fn test_fixed_target_long() { + let target = FixedTarget::new(0.04); + let result = target.calculate_target(100.0, None, Direction::Long); + assert!((result.unwrap() - 104.0).abs() < 1e-10); + } + + #[test] + fn test_risk_reward_target() { + let target = RiskRewardTarget::new(2.0); + // Entry at 100, stop at 98 (2% risk), target should be at 104 (4% reward) + let result = target.calculate_target(100.0, Some(98.0), Direction::Long); + assert!((result.unwrap() - 104.0).abs() < 1e-10); + } + + #[test] + fn test_risk_reward_no_stop() { + let target = RiskRewardTarget::new(2.0); + let result = target.calculate_target(100.0, None, Direction::Long); + assert!(result.is_none()); + } +} diff --git a/src/stops/mod.rs b/src/stops/mod.rs new file mode 100644 index 0000000..1baa81d --- /dev/null +++ b/src/stops/mod.rs @@ -0,0 +1,38 @@ +//! Stop-loss and take-profit mechanisms for RaptorBT. + +pub mod atr; +pub mod fixed; +pub mod trailing; + +pub use atr::AtrStop; +pub use fixed::FixedStop; +pub use trailing::TrailingStop; + +use crate::core::types::{Direction, Price}; + +/// Stop-loss calculator trait. +pub trait StopCalculator { + /// Calculate stop price for a new position. + fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option; + + /// Update stop price for trailing stops. + fn update_stop( + &self, + current_stop: Option, + current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option; +} + +/// Take-profit calculator trait. +pub trait TargetCalculator { + /// Calculate target price for a new position. + fn calculate_target( + &self, + entry_price: Price, + stop_price: Option, + direction: Direction, + ) -> Option; +} diff --git a/src/stops/trailing.rs b/src/stops/trailing.rs new file mode 100644 index 0000000..ec87a2b --- /dev/null +++ b/src/stops/trailing.rs @@ -0,0 +1,395 @@ +//! Trailing stop implementations. + +use super::StopCalculator; +use crate::core::types::{Direction, Price}; + +/// Percentage-based trailing stop. +#[derive(Debug, Clone, Copy)] +pub struct TrailingStop { + /// Trail percentage (e.g., 0.05 for 5%). + pub percent: f64, + /// Activation threshold (optional - start trailing after this profit %). + pub activation_threshold: Option, +} + +impl TrailingStop { + /// Create a new trailing stop. + pub fn new(percent: f64) -> Self { + Self { percent: percent.abs(), activation_threshold: None } + } + + /// Create with activation threshold. + pub fn with_activation(mut self, threshold: f64) -> Self { + self.activation_threshold = Some(threshold.abs()); + self + } + + /// Check if trailing should be activated. + #[allow(dead_code)] + fn should_activate( + &self, + entry_price: Price, + current_price: Price, + direction: Direction, + ) -> bool { + if let Some(threshold) = self.activation_threshold { + let profit_pct = match direction { + Direction::Long => (current_price - entry_price) / entry_price, + Direction::Short => (entry_price - current_price) / entry_price, + }; + profit_pct >= threshold + } else { + true // Always active if no threshold + } + } +} + +impl StopCalculator for TrailingStop { + fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option { + let stop = match direction { + Direction::Long => entry_price * (1.0 - self.percent), + Direction::Short => entry_price * (1.0 + self.percent), + }; + Some(stop) + } + + fn update_stop( + &self, + current_stop: Option, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + match direction { + Direction::Long => { + // Trail below the high + let new_stop = high * (1.0 - self.percent); + current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop)) + } + Direction::Short => { + // Trail above the low + let new_stop = low * (1.0 + self.percent); + current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop)) + } + } + } +} + +/// Point-based trailing stop (fixed point distance). +#[derive(Debug, Clone, Copy)] +pub struct PointTrailingStop { + /// Trail distance in points. + pub points: f64, +} + +impl PointTrailingStop { + /// Create a new point-based trailing stop. + pub fn new(points: f64) -> Self { + Self { points: points.abs() } + } +} + +impl StopCalculator for PointTrailingStop { + fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option { + let stop = match direction { + Direction::Long => entry_price - self.points, + Direction::Short => entry_price + self.points, + }; + Some(stop) + } + + fn update_stop( + &self, + current_stop: Option, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + match direction { + Direction::Long => { + let new_stop = high - self.points; + current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop)) + } + Direction::Short => { + let new_stop = low + self.points; + current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop)) + } + } + } +} + +/// Step trailing stop (moves in discrete steps). +#[derive(Debug, Clone, Copy)] +pub struct StepTrailingStop { + /// Step size percentage. + pub step_percent: f64, + /// Trail percentage from each step. + pub trail_percent: f64, +} + +impl StepTrailingStop { + /// Create a new step trailing stop. + pub fn new(step_percent: f64, trail_percent: f64) -> Self { + Self { step_percent: step_percent.abs(), trail_percent: trail_percent.abs() } + } + + /// Calculate stop for a given step level. + fn stop_for_step(&self, entry_price: Price, step: usize, direction: Direction) -> Price { + let step_gain = self.step_percent * step as f64; + match direction { + Direction::Long => { + let step_price = entry_price * (1.0 + step_gain); + step_price * (1.0 - self.trail_percent) + } + Direction::Short => { + let step_price = entry_price * (1.0 - step_gain); + step_price * (1.0 + self.trail_percent) + } + } + } + + /// Determine current step level. + #[allow(dead_code)] + fn current_step( + &self, + entry_price: Price, + extreme_price: Price, + direction: Direction, + ) -> usize { + let gain = match direction { + Direction::Long => (extreme_price - entry_price) / entry_price, + Direction::Short => (entry_price - extreme_price) / entry_price, + }; + + if gain <= 0.0 { + return 0; + } + + (gain / self.step_percent).floor() as usize + } +} + +impl StopCalculator for StepTrailingStop { + fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option { + Some(self.stop_for_step(entry_price, 0, direction)) + } + + fn update_stop( + &self, + current_stop: Option, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + // This is a simplified version - full implementation would need entry price + // For now, just use regular trailing behavior + match direction { + Direction::Long => { + let new_stop = high * (1.0 - self.trail_percent); + current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop)) + } + Direction::Short => { + let new_stop = low * (1.0 + self.trail_percent); + current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop)) + } + } + } +} + +/// Parabolic SAR style trailing stop. +#[derive(Debug, Clone)] +pub struct ParabolicStop { + /// Initial acceleration factor. + pub af_start: f64, + /// Acceleration factor increment. + pub af_step: f64, + /// Maximum acceleration factor. + pub af_max: f64, + /// Current acceleration factor. + current_af: f64, + /// Current extreme point. + extreme_point: f64, + /// Current SAR value. + current_sar: f64, +} + +impl ParabolicStop { + /// Create a new Parabolic SAR stop with default parameters. + pub fn new() -> Self { + Self::with_params(0.02, 0.02, 0.2) + } + + /// Create with custom parameters. + pub fn with_params(af_start: f64, af_step: f64, af_max: f64) -> Self { + Self { + af_start, + af_step, + af_max, + current_af: af_start, + extreme_point: 0.0, + current_sar: 0.0, + } + } + + /// Initialize for new position. + pub fn init(&mut self, entry_price: Price, direction: Direction) { + self.current_af = self.af_start; + self.extreme_point = entry_price; + self.current_sar = match direction { + Direction::Long => entry_price * 0.99, // Slightly below entry + Direction::Short => entry_price * 1.01, // Slightly above entry + }; + } + + /// Update SAR with new bar data. + pub fn update_sar(&mut self, high: Price, low: Price, direction: Direction) -> Price { + // Update extreme point + let new_ep = match direction { + Direction::Long => { + if high > self.extreme_point { + self.current_af = (self.current_af + self.af_step).min(self.af_max); + high + } else { + self.extreme_point + } + } + Direction::Short => { + if low < self.extreme_point { + self.current_af = (self.current_af + self.af_step).min(self.af_max); + low + } else { + self.extreme_point + } + } + }; + self.extreme_point = new_ep; + + // Calculate new SAR + let new_sar = self.current_sar + self.current_af * (self.extreme_point - self.current_sar); + + // Ensure SAR doesn't cross price + self.current_sar = match direction { + Direction::Long => new_sar.min(low), + Direction::Short => new_sar.max(high), + }; + + self.current_sar + } +} + +impl Default for ParabolicStop { + fn default() -> Self { + Self::new() + } +} + +impl StopCalculator for ParabolicStop { + fn calculate_stop(&self, _entry_price: Price, _direction: Direction) -> Option { + if self.current_sar > 0.0 { + Some(self.current_sar) + } else { + None + } + } + + fn update_stop( + &self, + _current_stop: Option, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // Parabolic stop is updated via update_sar method + if self.current_sar > 0.0 { + Some(self.current_sar) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trailing_stop_long() { + let stop = TrailingStop::new(0.05); + + // Initial stop + let initial = stop.calculate_stop(100.0, Direction::Long); + assert!((initial.unwrap() - 95.0).abs() < 1e-10); + + // Update with higher high + let updated = stop.update_stop(initial, 108.0, 110.0, 105.0, Direction::Long); + // 110 * 0.95 = 104.5 + assert!((updated.unwrap() - 104.5).abs() < 1e-10); + } + + #[test] + fn test_trailing_stop_short() { + let stop = TrailingStop::new(0.05); + + // Initial stop + let initial = stop.calculate_stop(100.0, Direction::Short); + assert!((initial.unwrap() - 105.0).abs() < 1e-10); + + // Update with lower low + let updated = stop.update_stop(initial, 92.0, 95.0, 90.0, Direction::Short); + // 90 * 1.05 = 94.5 + assert!((updated.unwrap() - 94.5).abs() < 1e-10); + } + + #[test] + fn test_trailing_stop_only_tightens() { + let stop = TrailingStop::new(0.05); + + let initial = stop.calculate_stop(100.0, Direction::Long); + + // Move up + let moved_up = stop.update_stop(initial, 110.0, 110.0, 108.0, Direction::Long); + // 110 * 0.95 = 104.5 + assert!((moved_up.unwrap() - 104.5).abs() < 1e-10); + + // Move down - stop should NOT move down + let moved_down = stop.update_stop(moved_up, 105.0, 106.0, 103.0, Direction::Long); + // Should still be 104.5 (not 106 * 0.95 = 100.7) + assert!((moved_down.unwrap() - 104.5).abs() < 1e-10); + } + + #[test] + fn test_point_trailing_stop() { + let stop = PointTrailingStop::new(5.0); + + // Initial stop + let initial = stop.calculate_stop(100.0, Direction::Long); + assert!((initial.unwrap() - 95.0).abs() < 1e-10); + + // Update with higher high + let updated = stop.update_stop(initial, 108.0, 110.0, 105.0, Direction::Long); + // 110 - 5 = 105 + assert!((updated.unwrap() - 105.0).abs() < 1e-10); + } + + #[test] + fn test_parabolic_stop() { + let mut stop = ParabolicStop::new(); + stop.init(100.0, Direction::Long); + + // Simulate uptrend + let sar1 = stop.update_sar(102.0, 99.0, Direction::Long); + let sar2 = stop.update_sar(105.0, 101.0, Direction::Long); + let sar3 = stop.update_sar(108.0, 103.0, Direction::Long); + + // SAR should be increasing + assert!(sar2 > sar1); + assert!(sar3 > sar2); + + // SAR should be below current low + assert!(sar3 < 103.0); + } +} diff --git a/src/strategies/basket.rs b/src/strategies/basket.rs new file mode 100644 index 0000000..4e8985a --- /dev/null +++ b/src/strategies/basket.rs @@ -0,0 +1,505 @@ +//! Basket/collective strategy backtest implementation. +//! +//! Supports multiple instruments with synchronized signals. + +use std::collections::HashMap; + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, InstrumentConfig, + OhlcvData, Trade, +}; +use crate::execution::FeeModel; +use crate::metrics::streaming::StreamingMetrics; +use crate::portfolio::allocation::{AllocationStrategy, CapitalAllocator}; +use crate::signals::processor::SignalProcessor; +use crate::signals::synchronizer::{SignalSynchronizer, SyncMode}; + +/// Basket backtest configuration. +#[derive(Debug, Clone)] +pub struct BasketConfig { + /// Base backtest config. + pub base: BacktestConfig, + /// Signal synchronization mode. + pub sync_mode: SyncMode, + /// Capital allocation strategy. + pub allocation: AllocationStrategy, + /// Whether to rebalance on each signal. + pub rebalance_on_signal: bool, +} + +impl Default for BasketConfig { + fn default() -> Self { + Self { + base: BacktestConfig::default(), + sync_mode: SyncMode::All, + allocation: AllocationStrategy::EqualWeight, + rebalance_on_signal: false, + } + } +} + +/// Basket/collective strategy backtest runner. +#[derive(Debug)] +pub struct BasketBacktest { + /// Configuration. + config: BasketConfig, + /// Signal synchronizer. + synchronizer: SignalSynchronizer, + /// Capital allocator. + #[allow(dead_code)] + allocator: CapitalAllocator, + /// Signal processor. + signal_processor: SignalProcessor, + /// Fee model. + fee_model: FeeModel, +} + +impl BasketBacktest { + /// Create a new basket backtest. + pub fn new(config: BasketConfig) -> Self { + let allocator = CapitalAllocator::new(config.base.initial_capital) + .with_strategy(config.allocation.clone()); + + Self { + synchronizer: SignalSynchronizer::new(config.sync_mode), + allocator, + signal_processor: SignalProcessor::new(), + fee_model: FeeModel::percentage(config.base.fees), + config, + } + } + + /// Run basket backtest with multiple instruments. + /// + /// # Arguments + /// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument + /// + /// # Returns + /// Combined backtest result + pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult { + self.run_with_instrument_configs(instruments, None) + } + + /// Run basket backtest with optional per-instrument configurations. + /// + /// # Arguments + /// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument + /// * `instrument_configs` - Optional map of symbol -> InstrumentConfig + /// + /// # Returns + /// Combined backtest result + pub fn run_with_instrument_configs( + &self, + instruments: &[(OhlcvData, CompiledSignals)], + instrument_configs: Option<&HashMap>, + ) -> BacktestResult { + if instruments.is_empty() { + return self.empty_result(); + } + + let n_instruments = instruments.len(); + let n_bars = instruments[0].0.len(); + + // Verify all instruments have same length + for (ohlcv, signals) in instruments { + assert_eq!(ohlcv.len(), n_bars, "All instruments must have same number of bars"); + assert_eq!(signals.len(), n_bars, "Signals must match OHLCV length"); + } + + // Synchronize signals + let entry_signals: Vec<&[bool]> = + instruments.iter().map(|(_, s)| s.entries.as_slice()).collect(); + let exit_signals: Vec<&[bool]> = + instruments.iter().map(|(_, s)| s.exits.as_slice()).collect(); + + let synced_entries = self.synchronizer.sync_entries(&entry_signals); + let synced_exits = self.synchronizer.sync_exits(&exit_signals); + + // Clean signals + let (clean_entries, clean_exits) = + self.signal_processor.clean_signals(&synced_entries, &synced_exits); + + // Initialize state + let mut cash = self.config.base.initial_capital; + let mut positions: Vec> = vec![None; n_instruments]; + let mut equity_curve = vec![cash; n_bars]; + let mut drawdown_curve = vec![0.0; n_bars]; + let mut returns = vec![0.0; n_bars]; + let mut trades: Vec = Vec::new(); + let mut streaming = StreamingMetrics::new(); + let mut peak_equity = cash; + let mut trade_counter = 0u64; + + // Main simulation loop + for i in 0..n_bars { + // Calculate current position values + let mut _total_position_value = 0.0; + for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() { + if let Some(ref pos) = positions[inst_idx] { + _total_position_value += pos.size * ohlcv.close[i]; + } + } + + // Check for exit + if clean_exits[i] { + for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() { + if let Some(pos) = positions[inst_idx].take() { + let exit_price = ohlcv.close[i]; + let fees = + self.fee_model.calculate(exit_price, pos.size, signals.direction); + + let pnl = (exit_price - pos.entry_price) + * pos.size + * signals.direction.multiplier() + - fees; + + let cost_basis = pos.entry_price * pos.size; + let return_pct = + if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 }; + + cash += exit_price * pos.size - fees; + + trades.push(Trade { + id: trade_counter, + symbol: signals.symbol.clone(), + entry_idx: pos.entry_idx, + exit_idx: i, + entry_price: pos.entry_price, + exit_price, + size: pos.size, + direction: signals.direction, + pnl, + return_pct, + entry_time: ohlcv.timestamps[pos.entry_idx], + exit_time: ohlcv.timestamps[i], + fees, + exit_reason: ExitReason::Signal, + }); + + trade_counter += 1; + streaming.update(return_pct / 100.0); + } + } + } + + // Check for entry + if clean_entries[i] && positions.iter().all(|p| p.is_none()) { + // Calculate position sizes + let prices: Vec = instruments.iter().map(|(o, _)| o.close[i]).collect(); + let weights: Vec = instruments.iter().map(|(_, s)| s.weight).collect(); + let symbols: Vec<&str> = + instruments.iter().map(|(_, s)| s.symbol.as_str()).collect(); + let sizes = self.calculate_sizes_with_configs( + &prices, + &weights, + cash, + &symbols, + instrument_configs, + ); + + // Enter positions + for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() { + let size = sizes[inst_idx]; + if size > 0.0 { + let entry_price = ohlcv.close[i]; + let fees = self.fee_model.calculate(entry_price, size, signals.direction); + cash -= entry_price * size + fees; + + positions[inst_idx] = + Some(PositionState { entry_idx: i, entry_price, size }); + } + } + } + + // Update equity + let mut position_value = 0.0; + for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() { + if let Some(ref pos) = positions[inst_idx] { + position_value += pos.size * ohlcv.close[i]; + } + } + let equity = cash + position_value; + equity_curve[i] = equity; + + // Update drawdown + if equity > peak_equity { + peak_equity = equity; + } + drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0; + + // Calculate return + if i > 0 { + returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1]; + } + } + + // Close any remaining positions + let last_idx = n_bars - 1; + for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() { + if let Some(pos) = positions[inst_idx].take() { + let exit_price = ohlcv.close[last_idx]; + let fees = self.fee_model.calculate(exit_price, pos.size, signals.direction); + + let pnl = + (exit_price - pos.entry_price) * pos.size * signals.direction.multiplier() + - fees; + + let cost_basis = pos.entry_price * pos.size; + let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 }; + + trades.push(Trade { + id: trade_counter, + symbol: signals.symbol.clone(), + entry_idx: pos.entry_idx, + exit_idx: last_idx, + entry_price: pos.entry_price, + exit_price, + size: pos.size, + direction: signals.direction, + pnl, + return_pct, + entry_time: ohlcv.timestamps[pos.entry_idx], + exit_time: ohlcv.timestamps[last_idx], + fees, + exit_reason: ExitReason::EndOfData, + }); + + trade_counter += 1; + streaming.update(return_pct / 100.0); + } + } + + // Calculate metrics + let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming); + + BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns) + } + + /// Calculate position sizes for each instrument. + #[allow(dead_code)] + fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec { + let symbols: Vec<&str> = vec![""; prices.len()]; + self.calculate_sizes_with_configs(prices, weights, available_capital, &symbols, None) + } + + /// Calculate position sizes with optional per-instrument config (lot_size rounding, capital caps). + fn calculate_sizes_with_configs( + &self, + prices: &[f64], + weights: &[f64], + available_capital: f64, + symbols: &[&str], + instrument_configs: Option<&HashMap>, + ) -> Vec { + let n = prices.len(); + let total_weight: f64 = weights.iter().sum(); + + if total_weight == 0.0 { + return vec![0.0; n]; + } + + prices + .iter() + .zip(weights.iter()) + .enumerate() + .map(|(idx, (&price, &weight))| { + if price <= 0.0 { + return 0.0; + } + let default_allocation = available_capital * (weight / total_weight); + + // Use per-instrument alloted_capital if set, capped at default allocation + let inst_config = instrument_configs + .and_then(|configs| symbols.get(idx).and_then(|sym| configs.get(*sym))); + + let allocation = inst_config + .and_then(|ic| ic.alloted_capital) + .map(|cap| cap.min(default_allocation)) + .unwrap_or(default_allocation); + + let raw_size = allocation / price; + + // Round to lot_size + inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size) + }) + .collect() + } + + /// Calculate metrics for the backtest. + fn calculate_metrics( + &self, + equity_curve: &[f64], + drawdown_curve: &[f64], + trades: &[Trade], + streaming: &StreamingMetrics, + ) -> BacktestMetrics { + let start_value = self.config.base.initial_capital; + let end_value = *equity_curve.last().unwrap_or(&start_value); + + let total_return_pct = (end_value - start_value) / start_value * 100.0; + let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b)); + + let total_trades = trades.len(); + let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count(); + let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count(); + + let win_rate_pct = if total_trades > 0 { + winning_trades as f64 / total_trades as f64 * 100.0 + } else { + 0.0 + }; + + let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum(); + let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum(); + let profit_factor = if gross_loss > 0.0 { + gross_profit / gross_loss + } else if gross_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + let sharpe_ratio = streaming.sharpe_ratio(252.0); + let sortino_ratio = streaming.sortino_ratio(252.0); + let calmar_ratio = if max_drawdown_pct > 0.0 { + total_return_pct / max_drawdown_pct + } else if total_return_pct > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + BacktestMetrics { + total_return_pct, + sharpe_ratio, + sortino_ratio, + calmar_ratio, + max_drawdown_pct, + win_rate_pct, + profit_factor, + total_trades, + winning_trades, + losing_trades, + start_value, + end_value, + ..Default::default() + } + } + + /// Create empty result. + fn empty_result(&self) -> BacktestResult { + BacktestResult::new( + BacktestMetrics { + start_value: self.config.base.initial_capital, + end_value: self.config.base.initial_capital, + ..Default::default() + }, + vec![], + vec![], + vec![], + vec![], + ) + } +} + +/// Internal position state. +#[derive(Debug, Clone)] +struct PositionState { + entry_idx: usize, + entry_price: f64, + size: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::Direction; + + fn sample_instruments() -> Vec<(OhlcvData, CompiledSignals)> { + let n = 20; + + let ohlcv1 = OhlcvData { + timestamps: (0..n as i64).collect(), + open: (100..100 + n).map(|x| x as f64).collect(), + high: (101..101 + n).map(|x| x as f64).collect(), + low: (99..99 + n).map(|x| x as f64).collect(), + close: (100..100 + n).map(|x| x as f64 + 0.5).collect(), + volume: vec![1000.0; n], + }; + + let ohlcv2 = OhlcvData { + timestamps: (0..n as i64).collect(), + open: (50..50 + n).map(|x| x as f64).collect(), + high: (51..51 + n).map(|x| x as f64).collect(), + low: (49..49 + n).map(|x| x as f64).collect(), + close: (50..50 + n).map(|x| x as f64 + 0.25).collect(), + volume: vec![2000.0; n], + }; + + let mut entries1 = vec![false; n]; + let mut exits1 = vec![false; n]; + entries1[2] = true; + exits1[8] = true; + + let mut entries2 = vec![false; n]; + let mut exits2 = vec![false; n]; + entries2[2] = true; + exits2[8] = true; + + let signals1 = CompiledSignals { + symbol: "INST1".to_string(), + entries: entries1, + exits: exits1, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + let signals2 = CompiledSignals { + symbol: "INST2".to_string(), + entries: entries2, + exits: exits2, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + vec![(ohlcv1, signals1), (ohlcv2, signals2)] + } + + #[test] + fn test_basket_backtest() { + let config = BasketConfig::default(); + let backtest = BasketBacktest::new(config); + let instruments = sample_instruments(); + + let result = backtest.run(&instruments); + + // Should have trades for both instruments + assert!(result.trades.len() >= 2); + assert_eq!(result.equity_curve.len(), 20); + } + + #[test] + fn test_sync_mode_all() { + let config = BasketConfig { sync_mode: SyncMode::All, ..Default::default() }; + let backtest = BasketBacktest::new(config); + let instruments = sample_instruments(); + + let result = backtest.run(&instruments); + + // With All mode, both instruments should enter at same time + assert!(result.trades.len() >= 2); + } + + #[test] + fn test_empty_instruments() { + let config = BasketConfig::default(); + let backtest = BasketBacktest::new(config); + + let result = backtest.run(&[]); + + assert_eq!(result.trades.len(), 0); + assert!(result.equity_curve.is_empty()); + } +} diff --git a/src/strategies/mod.rs b/src/strategies/mod.rs new file mode 100644 index 0000000..c1fb5ee --- /dev/null +++ b/src/strategies/mod.rs @@ -0,0 +1,19 @@ +//! Strategy implementations for different backtest types. + +pub mod basket; +pub mod multi; +pub mod options; +pub mod pairs; +pub mod single; +pub mod spreads; +pub mod tick; + +pub use basket::BasketBacktest; +pub use multi::MultiStrategyBacktest; +pub use options::OptionsBacktest; +pub use pairs::PairsBacktest; +pub use single::SingleBacktest; +pub use spreads::{ + LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType, +}; +pub use tick::{TickBacktest, TickBacktestConfig}; diff --git a/src/strategies/multi.rs b/src/strategies/multi.rs new file mode 100644 index 0000000..b0a1c80 --- /dev/null +++ b/src/strategies/multi.rs @@ -0,0 +1,378 @@ +//! Multi-strategy backtest implementation. +//! +//! Supports running multiple strategies on the same instrument. + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, OhlcvData, Trade, +}; +use crate::execution::FeeModel; +use crate::metrics::streaming::StreamingMetrics; + +/// Strategy combination mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CombineMode { + /// Enter when any strategy signals. + Any, + /// Enter when all strategies signal. + All, + /// Enter when majority of strategies signal. + Majority, + /// Run strategies independently with separate capital. + Independent, + /// Vote-weighted combination. + Weighted, +} + +impl Default for CombineMode { + fn default() -> Self { + CombineMode::Any + } +} + +/// Multi-strategy configuration. +#[derive(Debug, Clone)] +pub struct MultiStrategyConfig { + /// Base backtest config. + pub base: BacktestConfig, + /// Strategy combination mode. + pub combine_mode: CombineMode, + /// Capital allocation per strategy (for independent mode). + pub capital_per_strategy: Option, + /// Strategy weights (for weighted mode). + pub strategy_weights: Vec, +} + +impl Default for MultiStrategyConfig { + fn default() -> Self { + Self { + base: BacktestConfig::default(), + combine_mode: CombineMode::Any, + capital_per_strategy: None, + strategy_weights: vec![], + } + } +} + +/// Multi-strategy backtest runner. +#[derive(Debug)] +pub struct MultiStrategyBacktest { + /// Configuration. + config: MultiStrategyConfig, + /// Fee model. + #[allow(dead_code)] + fee_model: FeeModel, +} + +impl MultiStrategyBacktest { + /// Create a new multi-strategy backtest. + pub fn new(config: MultiStrategyConfig) -> Self { + Self { fee_model: FeeModel::percentage(config.base.fees), config } + } + + /// Run multi-strategy backtest. + /// + /// # Arguments + /// * `ohlcv` - OHLCV data for the instrument + /// * `strategies` - Vector of compiled signals from each strategy + /// + /// # Returns + /// Combined backtest result + pub fn run(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult { + if strategies.is_empty() { + return self.empty_result(); + } + + let n = ohlcv.len(); + for signals in strategies { + assert_eq!(signals.len(), n, "All strategies must have same length as OHLCV"); + } + + match self.config.combine_mode { + CombineMode::Independent => self.run_independent(ohlcv, strategies), + _ => self.run_combined(ohlcv, strategies), + } + } + + /// Run strategies independently with separate capital. + fn run_independent(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult { + let n_strategies = strategies.len(); + let capital_per = self + .config + .capital_per_strategy + .unwrap_or(self.config.base.initial_capital / n_strategies as f64); + + // Run each strategy independently + let mut all_trades: Vec = Vec::new(); + let mut strategy_equities: Vec> = Vec::new(); + + for (strat_idx, signals) in strategies.iter().enumerate() { + let single_config = + BacktestConfig { initial_capital: capital_per, ..self.config.base.clone() }; + let single = crate::strategies::single::SingleBacktest::new(single_config); + let result = single.run(ohlcv, signals); + + // Tag trades with strategy index + for mut trade in result.trades { + trade.symbol = format!("{}_{}", trade.symbol, strat_idx); + all_trades.push(trade); + } + + strategy_equities.push(result.equity_curve); + } + + // Combine equity curves + let n = ohlcv.len(); + let mut combined_equity = vec![0.0; n]; + for i in 0..n { + for equity in &strategy_equities { + combined_equity[i] += equity[i]; + } + } + + // Calculate drawdown + let mut peak = combined_equity[0]; + let mut drawdown_curve = vec![0.0; n]; + for i in 0..n { + if combined_equity[i] > peak { + peak = combined_equity[i]; + } + drawdown_curve[i] = (peak - combined_equity[i]) / peak * 100.0; + } + + // Calculate returns + let mut returns = vec![0.0; n]; + for i in 1..n { + returns[i] = (combined_equity[i] - combined_equity[i - 1]) / combined_equity[i - 1]; + } + + // Calculate metrics + let mut streaming = StreamingMetrics::new(); + for trade in &all_trades { + streaming.update(trade.return_pct / 100.0); + } + + let metrics = self.calculate_metrics( + &combined_equity, + &drawdown_curve, + &all_trades, + &streaming, + self.config.base.initial_capital, + ); + + BacktestResult::new(metrics, combined_equity, drawdown_curve, all_trades, returns) + } + + /// Run strategies with combined signals. + fn run_combined(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult { + let n = ohlcv.len(); + let n_strategies = strategies.len(); + + // Combine entry signals + let mut combined_entries = vec![false; n]; + let mut combined_exits = vec![false; n]; + + for i in 0..n { + let entry_count = strategies.iter().filter(|s| s.entries[i]).count(); + let exit_count = strategies.iter().filter(|s| s.exits[i]).count(); + + combined_entries[i] = match self.config.combine_mode { + CombineMode::Any => entry_count > 0, + CombineMode::All => entry_count == n_strategies, + CombineMode::Majority => entry_count > n_strategies / 2, + CombineMode::Weighted => { + let weighted_sum: f64 = strategies + .iter() + .enumerate() + .filter(|(_, s)| s.entries[i]) + .map(|(idx, _)| { + self.config.strategy_weights.get(idx).copied().unwrap_or(1.0) + }) + .sum(); + let total_weight: f64 = + self.config.strategy_weights.iter().sum::().max(n_strategies as f64); + weighted_sum / total_weight > 0.5 + } + CombineMode::Independent => unreachable!(), + }; + + // Exit when any strategy wants to exit (conservative) + combined_exits[i] = exit_count > 0; + } + + // Use first strategy's direction and symbol + let direction = strategies[0].direction; + let symbol = strategies[0].symbol.clone(); + + let combined_signals = CompiledSignals { + symbol, + entries: combined_entries, + exits: combined_exits, + position_sizes: None, + direction, + weight: 1.0, + }; + + // Run single backtest with combined signals + let single = crate::strategies::single::SingleBacktest::new(self.config.base.clone()); + single.run(ohlcv, &combined_signals) + } + + /// Calculate metrics. + fn calculate_metrics( + &self, + equity_curve: &[f64], + drawdown_curve: &[f64], + trades: &[Trade], + streaming: &StreamingMetrics, + initial_capital: f64, + ) -> BacktestMetrics { + let start_value = initial_capital; + let end_value = *equity_curve.last().unwrap_or(&start_value); + + let total_return_pct = (end_value - start_value) / start_value * 100.0; + let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b)); + + let total_trades = trades.len(); + let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count(); + let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count(); + + let win_rate_pct = if total_trades > 0 { + winning_trades as f64 / total_trades as f64 * 100.0 + } else { + 0.0 + }; + + let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum(); + let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum(); + let profit_factor = if gross_loss > 0.0 { + gross_profit / gross_loss + } else if gross_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + BacktestMetrics { + total_return_pct, + sharpe_ratio: streaming.sharpe_ratio(252.0), + sortino_ratio: streaming.sortino_ratio(252.0), + calmar_ratio: if max_drawdown_pct > 0.0 { + total_return_pct / max_drawdown_pct + } else { + 0.0 + }, + max_drawdown_pct, + win_rate_pct, + profit_factor, + total_trades, + winning_trades, + losing_trades, + start_value, + end_value, + ..Default::default() + } + } + + /// Create empty result. + fn empty_result(&self) -> BacktestResult { + BacktestResult::new( + BacktestMetrics { + start_value: self.config.base.initial_capital, + end_value: self.config.base.initial_capital, + ..Default::default() + }, + vec![], + vec![], + vec![], + vec![], + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::Direction; + + fn sample_strategies() -> (OhlcvData, Vec) { + let n = 20; + + let ohlcv = OhlcvData { + timestamps: (0..n as i64).collect(), + open: (100..100 + n).map(|x| x as f64).collect(), + high: (101..101 + n).map(|x| x as f64).collect(), + low: (99..99 + n).map(|x| x as f64).collect(), + close: (100..100 + n).map(|x| x as f64 + 0.5).collect(), + volume: vec![1000.0; n], + }; + + // Strategy 1: Early entry + let mut entries1 = vec![false; n]; + let mut exits1 = vec![false; n]; + entries1[2] = true; + exits1[8] = true; + + // Strategy 2: Later entry + let mut entries2 = vec![false; n]; + let mut exits2 = vec![false; n]; + entries2[4] = true; + exits2[10] = true; + + let signals1 = CompiledSignals { + symbol: "TEST".to_string(), + entries: entries1, + exits: exits1, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + let signals2 = CompiledSignals { + symbol: "TEST".to_string(), + entries: entries2, + exits: exits2, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + (ohlcv, vec![signals1, signals2]) + } + + #[test] + fn test_multi_any_mode() { + let config = MultiStrategyConfig { combine_mode: CombineMode::Any, ..Default::default() }; + let backtest = MultiStrategyBacktest::new(config); + let (ohlcv, strategies) = sample_strategies(); + + let result = backtest.run(&ohlcv, &strategies); + + // With Any mode, should enter at index 2 (first strategy) + assert!(!result.trades.is_empty()); + } + + #[test] + fn test_multi_all_mode() { + let config = MultiStrategyConfig { combine_mode: CombineMode::All, ..Default::default() }; + let backtest = MultiStrategyBacktest::new(config); + let (ohlcv, strategies) = sample_strategies(); + + let result = backtest.run(&ohlcv, &strategies); + + // With All mode, should not enter (strategies don't signal at same time) + assert!(result.trades.is_empty() || result.trades.len() < 2); + } + + #[test] + fn test_multi_independent_mode() { + let config = + MultiStrategyConfig { combine_mode: CombineMode::Independent, ..Default::default() }; + let backtest = MultiStrategyBacktest::new(config); + let (ohlcv, strategies) = sample_strategies(); + + let result = backtest.run(&ohlcv, &strategies); + + // With Independent mode, should have trades from both strategies + assert!(result.trades.len() >= 2); + } +} diff --git a/src/strategies/options.rs b/src/strategies/options.rs new file mode 100644 index 0000000..cb6b4fc --- /dev/null +++ b/src/strategies/options.rs @@ -0,0 +1,430 @@ +//! Options strategy backtest implementation. +//! +//! Supports dynamic strike selection and options-specific position sizing. + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade, +}; +use crate::execution::FeeModel; +use crate::metrics::streaming::StreamingMetrics; + +/// Options position type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OptionType { + Call, + Put, +} + +/// Strike selection mode. +#[derive(Debug, Clone, Copy)] +pub enum StrikeSelection { + /// At-the-money (closest to spot). + Atm, + /// In-the-money by N strikes. + Itm(usize), + /// Out-of-the-money by N strikes. + Otm(usize), + /// Fixed strike offset from ATM in percentage. + PercentOffset(f64), + /// Delta-based selection. + Delta(f64), +} + +impl Default for StrikeSelection { + fn default() -> Self { + StrikeSelection::Atm + } +} + +/// Position size type for options. +#[derive(Debug, Clone, Copy)] +pub enum SizeType { + /// Fixed number of contracts. + Contracts(usize), + /// Percentage of capital. + Percent(f64), + /// Fixed notional value. + Notional(f64), + /// Risk-based (percentage of capital at risk). + RiskPercent(f64), +} + +impl Default for SizeType { + fn default() -> Self { + SizeType::Percent(1.0) + } +} + +/// Options backtest configuration. +#[derive(Debug, Clone)] +pub struct OptionsConfig { + /// Base backtest config. + pub base: BacktestConfig, + /// Option type (call/put). + pub option_type: OptionType, + /// Strike selection mode. + pub strike_selection: StrikeSelection, + /// Position size type. + pub size_type: SizeType, + /// Lot size (contracts per lot). + pub lot_size: usize, + /// Strike interval. + pub strike_interval: f64, + /// Days to expiry preference. + pub target_dte: Option, +} + +impl Default for OptionsConfig { + fn default() -> Self { + Self { + base: BacktestConfig::default(), + option_type: OptionType::Call, + strike_selection: StrikeSelection::Atm, + size_type: SizeType::Percent(1.0), + lot_size: 1, + strike_interval: 50.0, + target_dte: None, + } + } +} + +/// Options backtest runner. +#[derive(Debug)] +pub struct OptionsBacktest { + /// Configuration. + config: OptionsConfig, + /// Fee model. + fee_model: FeeModel, +} + +impl OptionsBacktest { + /// Create a new options backtest. + pub fn new(config: OptionsConfig) -> Self { + Self { fee_model: FeeModel::percentage(config.base.fees), config } + } + + /// Run options backtest. + /// + /// # Arguments + /// * `spot_ohlcv` - Spot/underlying OHLCV data + /// * `option_prices` - Option premium prices (parallel array) + /// * `signals` - Trading signals + /// + /// # Returns + /// Backtest result + pub fn run( + &self, + spot_ohlcv: &OhlcvData, + option_prices: &[f64], + signals: &CompiledSignals, + ) -> BacktestResult { + let n = spot_ohlcv.len(); + assert_eq!(n, option_prices.len()); + assert_eq!(n, signals.len()); + + // Clean signals + let processor = crate::signals::processor::SignalProcessor::new(); + let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits); + + // Initialize state + let mut cash = self.config.base.initial_capital; + let mut position: Option = None; + let mut equity_curve = vec![cash; n]; + let mut drawdown_curve = vec![0.0; n]; + let mut returns = vec![0.0; n]; + let mut trades: Vec = Vec::new(); + let mut streaming = StreamingMetrics::new(); + let mut peak_equity = cash; + let mut trade_counter = 0u64; + + // Main simulation loop + for i in 0..n { + let spot_price = spot_ohlcv.close[i]; + let option_price = option_prices[i]; + + // Check for exit + if exits[i] { + if let Some(pos) = position.take() { + let exit_price = option_price; + let fees = self.fee_model.calculate( + exit_price, + pos.contracts as f64, + signals.direction, + ); + + let pnl = self.calculate_pnl(&pos, exit_price) - fees; + let cost_basis = + pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64; + let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 }; + + cash += exit_price * pos.contracts as f64 * self.config.lot_size as f64 - fees; + + trades.push(Trade { + id: trade_counter, + symbol: signals.symbol.clone(), + entry_idx: pos.entry_idx, + exit_idx: i, + entry_price: pos.entry_price, + exit_price, + size: pos.contracts as f64, + direction: signals.direction, + pnl, + return_pct, + entry_time: spot_ohlcv.timestamps[pos.entry_idx], + exit_time: spot_ohlcv.timestamps[i], + fees, + exit_reason: ExitReason::Signal, + }); + + trade_counter += 1; + streaming.update(return_pct / 100.0); + } + } + + // Check for entry + if entries[i] && position.is_none() { + let strike = self.select_strike(spot_price); + let contracts = self.calculate_contracts(option_price, cash); + + if contracts > 0 { + let entry_cost = option_price * contracts as f64 * self.config.lot_size as f64; + let fees = + self.fee_model.calculate(option_price, contracts as f64, signals.direction); + + cash -= entry_cost + fees; + + position = Some(OptionsPosition { + entry_idx: i, + entry_price: option_price, + strike, + contracts, + option_type: self.config.option_type, + }); + } + } + + // Update equity + let position_value = if let Some(ref pos) = position { + option_price * pos.contracts as f64 * self.config.lot_size as f64 + } else { + 0.0 + }; + let equity = cash + position_value; + equity_curve[i] = equity; + + // Update drawdown + if equity > peak_equity { + peak_equity = equity; + } + drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0; + + // Calculate return + if i > 0 { + returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1]; + } + } + + // Close any remaining position + if let Some(pos) = position.take() { + let last_idx = n - 1; + let exit_price = option_prices[last_idx]; + let fees = + self.fee_model.calculate(exit_price, pos.contracts as f64, signals.direction); + + let pnl = self.calculate_pnl(&pos, exit_price) - fees; + let cost_basis = pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64; + let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 }; + + trades.push(Trade { + id: trade_counter, + symbol: signals.symbol.clone(), + entry_idx: pos.entry_idx, + exit_idx: last_idx, + entry_price: pos.entry_price, + exit_price, + size: pos.contracts as f64, + direction: signals.direction, + pnl, + return_pct, + entry_time: spot_ohlcv.timestamps[pos.entry_idx], + exit_time: spot_ohlcv.timestamps[last_idx], + fees, + exit_reason: ExitReason::EndOfData, + }); + + streaming.update(return_pct / 100.0); + } + + // Calculate metrics + let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming); + + BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns) + } + + /// Select strike price based on configuration. + fn select_strike(&self, spot_price: f64) -> f64 { + let interval = self.config.strike_interval; + let atm_strike = (spot_price / interval).round() * interval; + + match self.config.strike_selection { + StrikeSelection::Atm => atm_strike, + StrikeSelection::Itm(n) => match self.config.option_type { + OptionType::Call => atm_strike - (n as f64 * interval), + OptionType::Put => atm_strike + (n as f64 * interval), + }, + StrikeSelection::Otm(n) => match self.config.option_type { + OptionType::Call => atm_strike + (n as f64 * interval), + OptionType::Put => atm_strike - (n as f64 * interval), + }, + StrikeSelection::PercentOffset(pct) => { + let offset = spot_price * pct; + match self.config.option_type { + OptionType::Call => atm_strike + offset, + OptionType::Put => atm_strike - offset, + } + } + StrikeSelection::Delta(_) => atm_strike, // Simplified - would need options chain + } + } + + /// Calculate number of contracts based on size type. + fn calculate_contracts(&self, option_price: f64, available_capital: f64) -> usize { + if option_price <= 0.0 { + return 0; + } + + let contract_cost = option_price * self.config.lot_size as f64; + + match self.config.size_type { + SizeType::Contracts(n) => n, + SizeType::Percent(pct) => { + let allocation = available_capital * pct; + (allocation / contract_cost) as usize + } + SizeType::Notional(value) => (value / contract_cost) as usize, + SizeType::RiskPercent(pct) => { + // Max loss is the premium paid + let risk_amount = available_capital * pct; + (risk_amount / contract_cost) as usize + } + } + } + + /// Calculate P&L for a position. + fn calculate_pnl(&self, position: &OptionsPosition, current_price: f64) -> f64 { + let multiplier = self.config.lot_size as f64; + (current_price - position.entry_price) * position.contracts as f64 * multiplier + } + + /// Calculate metrics. + fn calculate_metrics( + &self, + equity_curve: &[f64], + drawdown_curve: &[f64], + trades: &[Trade], + streaming: &StreamingMetrics, + ) -> BacktestMetrics { + let start_value = self.config.base.initial_capital; + let end_value = *equity_curve.last().unwrap_or(&start_value); + + let total_return_pct = (end_value - start_value) / start_value * 100.0; + let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b)); + + let total_trades = trades.len(); + let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count(); + let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count(); + + let win_rate_pct = if total_trades > 0 { + winning_trades as f64 / total_trades as f64 * 100.0 + } else { + 0.0 + }; + + let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum(); + let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum(); + let profit_factor = if gross_loss > 0.0 { + gross_profit / gross_loss + } else if gross_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + BacktestMetrics { + total_return_pct, + sharpe_ratio: streaming.sharpe_ratio(252.0), + sortino_ratio: streaming.sortino_ratio(252.0), + calmar_ratio: if max_drawdown_pct > 0.0 { + total_return_pct / max_drawdown_pct + } else { + 0.0 + }, + max_drawdown_pct, + win_rate_pct, + profit_factor, + total_trades, + winning_trades, + losing_trades, + start_value, + end_value, + ..Default::default() + } + } +} + +/// Internal options position state. +#[derive(Debug, Clone)] +struct OptionsPosition { + entry_idx: usize, + entry_price: f64, + #[allow(dead_code)] + strike: f64, + contracts: usize, + #[allow(dead_code)] + option_type: OptionType, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strike_selection_atm() { + let config = OptionsConfig { + strike_interval: 50.0, + strike_selection: StrikeSelection::Atm, + ..Default::default() + }; + let backtest = OptionsBacktest::new(config); + + // Spot at 17834, ATM should be 17850 + let strike = backtest.select_strike(17834.0); + assert!((strike - 17850.0).abs() < 1e-10); + } + + #[test] + fn test_strike_selection_otm() { + let config = OptionsConfig { + strike_interval: 50.0, + strike_selection: StrikeSelection::Otm(2), + option_type: OptionType::Call, + ..Default::default() + }; + let backtest = OptionsBacktest::new(config); + + // Spot at 17834, ATM=17850, OTM 2 strikes = 17950 + let strike = backtest.select_strike(17834.0); + assert!((strike - 17950.0).abs() < 1e-10); + } + + #[test] + fn test_position_sizing_percent() { + let config = + OptionsConfig { size_type: SizeType::Percent(0.5), lot_size: 50, ..Default::default() }; + let backtest = OptionsBacktest::new(config); + + // 50% of 100000 = 50000, option at 100 * lot 50 = 5000 per contract + let contracts = backtest.calculate_contracts(100.0, 100_000.0); + assert_eq!(contracts, 10); + } +} diff --git a/src/strategies/pairs.rs b/src/strategies/pairs.rs new file mode 100644 index 0000000..10c6391 --- /dev/null +++ b/src/strategies/pairs.rs @@ -0,0 +1,453 @@ +//! Pairs trading strategy backtest implementation. +//! +//! Supports long/short legs with hedge ratios. + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason, + OhlcvData, Trade, +}; +use crate::execution::FeeModel; +use crate::metrics::streaming::StreamingMetrics; + +/// Pairs trading configuration. +#[derive(Debug, Clone)] +pub struct PairsConfig { + /// Base backtest config. + pub base: BacktestConfig, + /// Hedge ratio (units of leg2 per unit of leg1). + pub hedge_ratio: f64, + /// Whether to dynamically update hedge ratio. + pub dynamic_hedge: bool, + /// Lookback period for dynamic hedge calculation. + pub hedge_lookback: usize, + /// Maximum spread for entry. + pub max_spread: Option, + /// Entry z-score threshold. + pub entry_zscore: f64, + /// Exit z-score threshold. + pub exit_zscore: f64, +} + +impl Default for PairsConfig { + fn default() -> Self { + Self { + base: BacktestConfig::default(), + hedge_ratio: 1.0, + dynamic_hedge: false, + hedge_lookback: 20, + max_spread: None, + entry_zscore: 2.0, + exit_zscore: 0.5, + } + } +} + +/// Pairs trading backtest runner. +#[derive(Debug)] +pub struct PairsBacktest { + /// Configuration. + config: PairsConfig, + /// Fee model. + fee_model: FeeModel, +} + +impl PairsBacktest { + /// Create a new pairs backtest. + pub fn new(config: PairsConfig) -> Self { + Self { fee_model: FeeModel::percentage(config.base.fees), config } + } + + /// Run pairs trading backtest. + /// + /// # Arguments + /// * `leg1_ohlcv` - OHLCV data for leg 1 (long leg when spread widens) + /// * `leg2_ohlcv` - OHLCV data for leg 2 (short leg when spread widens) + /// * `signals` - Entry/exit signals based on spread + /// + /// # Returns + /// Backtest result + pub fn run( + &self, + leg1_ohlcv: &OhlcvData, + leg2_ohlcv: &OhlcvData, + signals: &CompiledSignals, + ) -> BacktestResult { + let n = leg1_ohlcv.len(); + assert_eq!(n, leg2_ohlcv.len()); + assert_eq!(n, signals.len()); + + // Clean signals + let processor = crate::signals::processor::SignalProcessor::new(); + let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits); + + // Initialize state + let mut cash = self.config.base.initial_capital; + let mut position: Option = None; + let mut equity_curve = vec![cash; n]; + let mut drawdown_curve = vec![0.0; n]; + let mut returns = vec![0.0; n]; + let mut trades: Vec = Vec::new(); + let mut streaming = StreamingMetrics::new(); + let mut peak_equity = cash; + let mut trade_counter = 0u64; + + // Main simulation loop + for i in 0..n { + let leg1_price = leg1_ohlcv.close[i]; + let leg2_price = leg2_ohlcv.close[i]; + + // Calculate current hedge ratio + let hedge_ratio = if self.config.dynamic_hedge && i >= self.config.hedge_lookback { + self.calculate_hedge_ratio( + &leg1_ohlcv.close[i - self.config.hedge_lookback..=i], + &leg2_ohlcv.close[i - self.config.hedge_lookback..=i], + ) + } else { + self.config.hedge_ratio + }; + + // Check for exit + if exits[i] { + if let Some(pos) = position.take() { + let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price); + let cost_basis = pos.leg1_cost + pos.leg2_cost; + let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 }; + + // Return capital + cash += pos.leg1_size * leg1_price + pos.leg2_size * leg2_price - fees; + + // Record trades for both legs + trades.push(Trade { + id: trade_counter, + symbol: format!("{}_LEG1", signals.symbol), + entry_idx: pos.entry_idx, + exit_idx: i, + entry_price: pos.leg1_entry_price, + exit_price: leg1_price, + size: pos.leg1_size, + direction: pos.leg1_direction, + pnl: pnl / 2.0, // Split P&L attribution + return_pct: return_pct / 2.0, + entry_time: leg1_ohlcv.timestamps[pos.entry_idx], + exit_time: leg1_ohlcv.timestamps[i], + fees: fees / 2.0, + exit_reason: ExitReason::Signal, + }); + + trade_counter += 1; + + trades.push(Trade { + id: trade_counter, + symbol: format!("{}_LEG2", signals.symbol), + entry_idx: pos.entry_idx, + exit_idx: i, + entry_price: pos.leg2_entry_price, + exit_price: leg2_price, + size: pos.leg2_size, + direction: pos.leg2_direction, + pnl: pnl / 2.0, + return_pct: return_pct / 2.0, + entry_time: leg2_ohlcv.timestamps[pos.entry_idx], + exit_time: leg2_ohlcv.timestamps[i], + fees: fees / 2.0, + exit_reason: ExitReason::Signal, + }); + + trade_counter += 1; + streaming.update(return_pct / 100.0); + } + } + + // Check for entry + if entries[i] && position.is_none() { + // Determine direction from signal direction + let (leg1_dir, leg2_dir) = match signals.direction { + Direction::Long => (Direction::Long, Direction::Short), + Direction::Short => (Direction::Short, Direction::Long), + }; + + // Calculate position sizes + let allocation = cash * 0.5; // Use 50% per leg + let leg1_size = allocation / leg1_price; + let leg2_size = (allocation * hedge_ratio) / leg2_price; + + let leg1_cost = leg1_size * leg1_price; + let leg2_cost = leg2_size * leg2_price; + let entry_fees = self.fee_model.calculate(leg1_price, leg1_size, leg1_dir) + + self.fee_model.calculate(leg2_price, leg2_size, leg2_dir); + + cash -= leg1_cost + leg2_cost + entry_fees; + + position = Some(PairsPosition { + entry_idx: i, + leg1_entry_price: leg1_price, + leg2_entry_price: leg2_price, + leg1_size, + leg2_size, + leg1_direction: leg1_dir, + leg2_direction: leg2_dir, + leg1_cost, + leg2_cost, + hedge_ratio, + }); + } + + // Update equity + let position_value = if let Some(ref pos) = position { + let _leg1_value = pos.leg1_size * leg1_price; + let _leg2_value = pos.leg2_size * leg2_price; + + // For pairs, value is long leg - short leg + cash equivalent + let leg1_pnl = (leg1_price - pos.leg1_entry_price) + * pos.leg1_size + * pos.leg1_direction.multiplier(); + let leg2_pnl = (leg2_price - pos.leg2_entry_price) + * pos.leg2_size + * pos.leg2_direction.multiplier(); + + pos.leg1_cost + pos.leg2_cost + leg1_pnl + leg2_pnl + } else { + 0.0 + }; + + let equity = cash + position_value; + equity_curve[i] = equity; + + // Update drawdown + if equity > peak_equity { + peak_equity = equity; + } + drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0; + + // Calculate return + if i > 0 { + returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1]; + } + } + + // Close any remaining position + if let Some(pos) = position.take() { + let last_idx = n - 1; + let leg1_price = leg1_ohlcv.close[last_idx]; + let leg2_price = leg2_ohlcv.close[last_idx]; + + let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price); + let cost_basis = pos.leg1_cost + pos.leg2_cost; + let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 }; + + trades.push(Trade { + id: trade_counter, + symbol: signals.symbol.clone(), + entry_idx: pos.entry_idx, + exit_idx: last_idx, + entry_price: pos.leg1_entry_price, + exit_price: leg1_price, + size: pos.leg1_size + pos.leg2_size, + direction: pos.leg1_direction, + pnl, + return_pct, + entry_time: leg1_ohlcv.timestamps[pos.entry_idx], + exit_time: leg1_ohlcv.timestamps[last_idx], + fees, + exit_reason: ExitReason::EndOfData, + }); + + streaming.update(return_pct / 100.0); + } + + // Calculate metrics + let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming); + + BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns) + } + + /// Calculate hedge ratio using OLS regression. + fn calculate_hedge_ratio(&self, leg1_prices: &[f64], leg2_prices: &[f64]) -> f64 { + let n = leg1_prices.len() as f64; + if n < 2.0 { + return self.config.hedge_ratio; + } + + let sum_x: f64 = leg2_prices.iter().sum(); + let sum_y: f64 = leg1_prices.iter().sum(); + let sum_xy: f64 = leg1_prices.iter().zip(leg2_prices.iter()).map(|(y, x)| x * y).sum(); + let sum_x2: f64 = leg2_prices.iter().map(|x| x * x).sum(); + + let denominator = n * sum_x2 - sum_x * sum_x; + if denominator.abs() < 1e-10 { + return self.config.hedge_ratio; + } + + let beta = (n * sum_xy - sum_x * sum_y) / denominator; + beta.max(0.1).min(10.0) // Constrain to reasonable range + } + + /// Close position and calculate P&L. + fn close_position( + &self, + position: &PairsPosition, + leg1_price: f64, + leg2_price: f64, + ) -> (f64, f64) { + let leg1_pnl = (leg1_price - position.leg1_entry_price) + * position.leg1_size + * position.leg1_direction.multiplier(); + + let leg2_pnl = (leg2_price - position.leg2_entry_price) + * position.leg2_size + * position.leg2_direction.multiplier(); + + let exit_fees = + self.fee_model.calculate(leg1_price, position.leg1_size, position.leg1_direction) + + self.fee_model.calculate(leg2_price, position.leg2_size, position.leg2_direction); + + let total_pnl = leg1_pnl + leg2_pnl - exit_fees; + + (total_pnl, exit_fees) + } + + /// Calculate metrics. + fn calculate_metrics( + &self, + equity_curve: &[f64], + drawdown_curve: &[f64], + trades: &[Trade], + streaming: &StreamingMetrics, + ) -> BacktestMetrics { + let start_value = self.config.base.initial_capital; + let end_value = *equity_curve.last().unwrap_or(&start_value); + + let total_return_pct = (end_value - start_value) / start_value * 100.0; + let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b)); + + // For pairs, count trade pairs (every 2 trades = 1 round trip) + let total_trades = trades.len() / 2; + let winning_trades = + trades.chunks(2).filter(|chunk| chunk.iter().map(|t| t.pnl).sum::() > 0.0).count(); + let losing_trades = total_trades.saturating_sub(winning_trades); + + let win_rate_pct = if total_trades > 0 { + winning_trades as f64 / total_trades as f64 * 100.0 + } else { + 0.0 + }; + + let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum(); + let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum(); + let profit_factor = if gross_loss > 0.0 { + gross_profit / gross_loss + } else if gross_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + BacktestMetrics { + total_return_pct, + sharpe_ratio: streaming.sharpe_ratio(252.0), + sortino_ratio: streaming.sortino_ratio(252.0), + calmar_ratio: if max_drawdown_pct > 0.0 { + total_return_pct / max_drawdown_pct + } else { + 0.0 + }, + max_drawdown_pct, + win_rate_pct, + profit_factor, + total_trades, + winning_trades, + losing_trades, + start_value, + end_value, + ..Default::default() + } + } +} + +/// Internal pairs position state. +#[derive(Debug, Clone)] +struct PairsPosition { + entry_idx: usize, + leg1_entry_price: f64, + leg2_entry_price: f64, + leg1_size: f64, + leg2_size: f64, + leg1_direction: Direction, + leg2_direction: Direction, + leg1_cost: f64, + leg2_cost: f64, + #[allow(dead_code)] + hedge_ratio: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_pairs_data() -> (OhlcvData, OhlcvData, CompiledSignals) { + let n = 20; + + // Leg 1: Trending up + let leg1 = OhlcvData { + timestamps: (0..n as i64).collect(), + open: (100..100 + n).map(|x| x as f64).collect(), + high: (101..101 + n).map(|x| x as f64).collect(), + low: (99..99 + n).map(|x| x as f64).collect(), + close: (100..100 + n).map(|x| x as f64 + 0.5).collect(), + volume: vec![1000.0; n], + }; + + // Leg 2: Correlated but with different magnitude + let leg2 = OhlcvData { + timestamps: (0..n as i64).collect(), + open: (50..50 + n).map(|x| x as f64).collect(), + high: (51..51 + n).map(|x| x as f64).collect(), + low: (49..49 + n).map(|x| x as f64).collect(), + close: (50..50 + n).map(|x| x as f64 + 0.2).collect(), + volume: vec![2000.0; n], + }; + + let mut entries = vec![false; n]; + let mut exits = vec![false; n]; + entries[2] = true; + exits[10] = true; + + let signals = CompiledSignals { + symbol: "PAIR".to_string(), + entries, + exits, + position_sizes: None, + direction: Direction::Long, // Long leg1, short leg2 + weight: 1.0, + }; + + (leg1, leg2, signals) + } + + #[test] + fn test_pairs_backtest() { + let config = PairsConfig::default(); + let backtest = PairsBacktest::new(config); + let (leg1, leg2, signals) = sample_pairs_data(); + + let result = backtest.run(&leg1, &leg2, &signals); + + // Should have trades for both legs + assert!(result.trades.len() >= 2); + assert_eq!(result.equity_curve.len(), 20); + } + + #[test] + fn test_hedge_ratio_calculation() { + let config = PairsConfig { dynamic_hedge: true, hedge_lookback: 5, ..Default::default() }; + let backtest = PairsBacktest::new(config); + + let leg1 = vec![100.0, 102.0, 104.0, 106.0, 108.0]; + let leg2 = vec![50.0, 51.0, 52.0, 53.0, 54.0]; + + let ratio = backtest.calculate_hedge_ratio(&leg1, &leg2); + + // Ratio should be approximately 2 (leg1 moves 2x leg2) + assert!(ratio > 1.5 && ratio < 2.5); + } +} diff --git a/src/strategies/single.rs b/src/strategies/single.rs new file mode 100644 index 0000000..84ef85f --- /dev/null +++ b/src/strategies/single.rs @@ -0,0 +1,220 @@ +//! Single instrument backtest implementation. + +use crate::core::types::{ + BacktestConfig, BacktestResult, CompiledSignals, InstrumentConfig, OhlcvData, +}; +use crate::portfolio::engine::PortfolioEngine; + +/// Single instrument backtest runner. +#[derive(Debug)] +pub struct SingleBacktest { + /// Portfolio engine. + engine: PortfolioEngine, +} + +impl SingleBacktest { + /// Create a new single instrument backtest. + pub fn new(config: BacktestConfig) -> Self { + Self { engine: PortfolioEngine::new(config) } + } + + /// Run the backtest. + /// + /// # Arguments + /// * `ohlcv` - OHLCV price data + /// * `signals` - Compiled trading signals + /// + /// # Returns + /// Backtest result with metrics, trades, and equity curve + pub fn run(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult { + self.engine.run_single(ohlcv, signals) + } + + /// Run the backtest with per-instrument configuration. + /// + /// # Arguments + /// * `ohlcv` - OHLCV price data + /// * `signals` - Compiled trading signals + /// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides) + /// + /// # Returns + /// Backtest result with metrics, trades, and equity curve + pub fn run_with_instrument_config( + &self, + ohlcv: &OhlcvData, + signals: &CompiledSignals, + inst_config: Option<&InstrumentConfig>, + ) -> BacktestResult { + self.engine.run_single_with_instrument_config(ohlcv, signals, inst_config) + } + + /// Run backtest from raw arrays. + /// + /// # Arguments + /// * `timestamps` - Timestamp array + /// * `open` - Open prices + /// * `high` - High prices + /// * `low` - Low prices + /// * `close` - Close prices + /// * `volume` - Volume + /// * `entries` - Entry signals + /// * `exits` - Exit signals + /// * `direction` - Trade direction (1 = long, -1 = short) + /// * `symbol` - Symbol name + /// + /// # Returns + /// Backtest result + pub fn run_from_arrays( + &self, + timestamps: &[i64], + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + entries: &[bool], + exits: &[bool], + direction: i32, + symbol: &str, + ) -> BacktestResult { + let ohlcv = OhlcvData { + timestamps: timestamps.to_vec(), + open: open.to_vec(), + high: high.to_vec(), + low: low.to_vec(), + close: close.to_vec(), + volume: volume.to_vec(), + }; + + let dir = crate::core::types::Direction::from_int(direction) + .unwrap_or(crate::core::types::Direction::Long); + + let signals = CompiledSignals { + symbol: symbol.to_string(), + entries: entries.to_vec(), + exits: exits.to_vec(), + position_sizes: None, + direction: dir, + weight: 1.0, + }; + + self.run(&ohlcv, &signals) + } + + /// Run backtest with position sizing. + /// + /// # Arguments + /// * `ohlcv` - OHLCV price data + /// * `signals` - Compiled trading signals + /// * `position_sizes` - Position size for each bar (fraction of capital) + /// + /// # Returns + /// Backtest result + pub fn run_with_sizing( + &self, + ohlcv: &OhlcvData, + signals: &CompiledSignals, + position_sizes: Vec, + ) -> BacktestResult { + let mut signals_with_sizing = signals.clone(); + signals_with_sizing.position_sizes = Some(position_sizes); + self.engine.run_single(ohlcv, &signals_with_sizing) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::{Direction, StopConfig, TargetConfig}; + + fn sample_data() -> (OhlcvData, CompiledSignals) { + let ohlcv = OhlcvData { + timestamps: (0..20).map(|i| i as i64).collect(), + open: vec![ + 100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0, + 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, + ], + high: vec![ + 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0, + 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0, + ], + low: vec![ + 99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0, + 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, + ], + close: vec![ + 100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5, + 102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5, + ], + volume: vec![1000.0; 20], + }; + + let signals = CompiledSignals { + symbol: "TEST".to_string(), + entries: vec![ + false, true, false, false, false, false, false, false, false, false, false, true, + false, false, false, false, false, false, false, false, + ], + exits: vec![ + false, false, false, false, false, true, false, false, false, false, false, false, + false, false, false, true, false, false, false, false, + ], + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + (ohlcv, signals) + } + + #[test] + fn test_single_backtest() { + let config = BacktestConfig { + initial_capital: 100_000.0, + fees: 0.0, + slippage: 0.0, + stop: StopConfig::None, + target: TargetConfig::None, + upon_bar_close: true, + }; + + let backtest = SingleBacktest::new(config); + let (ohlcv, signals) = sample_data(); + + let result = backtest.run(&ohlcv, &signals); + + assert_eq!(result.trades.len(), 2); + assert!(result.metrics.total_return_pct > 0.0); + } + + #[test] + fn test_from_arrays() { + let config = BacktestConfig::default(); + let backtest = SingleBacktest::new(config); + + let timestamps: Vec = (0..10).collect(); + let close: Vec = (100..110).map(|x| x as f64).collect(); + let open = close.clone(); + let high: Vec = close.iter().map(|x| x + 1.0).collect(); + let low: Vec = close.iter().map(|x| x - 1.0).collect(); + let volume = vec![1000.0; 10]; + + let entries = vec![false, true, false, false, false, false, false, false, false, false]; + let exits = vec![false, false, false, false, false, true, false, false, false, false]; + + let result = backtest.run_from_arrays( + ×tamps, + &open, + &high, + &low, + &close, + &volume, + &entries, + &exits, + 1, + "TEST", + ); + + assert_eq!(result.trades.len(), 1); + } +} diff --git a/src/strategies/spreads.rs b/src/strategies/spreads.rs new file mode 100644 index 0000000..c7afced --- /dev/null +++ b/src/strategies/spreads.rs @@ -0,0 +1,606 @@ +//! Multi-leg options spread backtesting implementation. +//! +//! Provides high-performance spread backtesting for: +//! - Straddles and Strangles +//! - Vertical spreads (bull/bear call/put) +//! - Iron Condors and Iron Butterflies +//! - Calendar and Diagonal spreads +//! +//! Key features: +//! - Single-pass O(n) algorithm +//! - Coordinated entry/exit across all legs +//! - Net premium P&L calculation +//! - Combined Greeks tracking + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, Direction, ExitReason, Trade, +}; +use crate::metrics::streaming::StreamingMetrics; +use serde::{Deserialize, Serialize}; + +/// Spread type enumeration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SpreadType { + Straddle, + Strangle, + VerticalCall, + VerticalPut, + IronCondor, + IronButterfly, + ButterflyCall, + ButterflyPut, + Calendar, + Diagonal, + LongCall, + LongPut, + NakedCall, + NakedPut, + Custom, +} + +/// Option type for a leg. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OptionType { + Call, + Put, +} + +impl OptionType { + pub fn from_str(s: &str) -> Option { + match s.to_uppercase().as_str() { + "CE" | "CALL" | "C" => Some(OptionType::Call), + "PE" | "PUT" | "P" => Some(OptionType::Put), + _ => None, + } + } +} + +/// Configuration for a single leg of a spread. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LegConfig { + /// Option type (Call or Put). + pub option_type: OptionType, + /// Strike price. + pub strike: f64, + /// Position quantity (+1 long, -1 short). + pub quantity: i32, + /// Lot size for the option. + pub lot_size: usize, +} + +impl LegConfig { + pub fn new(option_type: OptionType, strike: f64, quantity: i32, lot_size: usize) -> Self { + Self { option_type, strike, quantity, lot_size } + } + + /// Check if this is a long position. + pub fn is_long(&self) -> bool { + self.quantity > 0 + } + + /// Check if this is a short position. + pub fn is_short(&self) -> bool { + self.quantity < 0 + } +} + +/// Configuration for spread backtest. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpreadConfig { + /// Base backtest configuration. + pub base: BacktestConfig, + /// Spread type. + pub spread_type: SpreadType, + /// Leg configurations. + pub leg_configs: Vec, + /// Maximum loss threshold (optional, for early exit). + pub max_loss: Option, + /// Target profit threshold (optional, for early exit). + pub target_profit: Option, + /// Whether to close at end of day. + pub close_at_eod: bool, + /// Per-leg expiry timestamps in nanoseconds (optional, for settlement logic). + /// When provided, positions are force-closed at or after the earliest leg expiry. + pub leg_expiry_timestamps: Option>, +} + +impl Default for SpreadConfig { + fn default() -> Self { + Self { + base: BacktestConfig::default(), + spread_type: SpreadType::Custom, + leg_configs: Vec::new(), + max_loss: None, + target_profit: None, + close_at_eod: false, + leg_expiry_timestamps: None, + } + } +} + +/// State for a single leg position. +#[derive(Debug, Clone)] +struct LegPosition { + /// Entry premium price. + pub entry_premium: f64, + /// Entry index. + #[allow(dead_code)] + pub entry_idx: usize, + /// Current premium price. + pub current_premium: f64, + /// Leg configuration. + pub config: LegConfig, +} + +impl LegPosition { + fn new(config: LegConfig, entry_premium: f64, entry_idx: usize) -> Self { + Self { entry_premium, entry_idx, current_premium: entry_premium, config } + } + + /// Calculate unrealized P&L for this leg. + fn unrealized_pnl(&self) -> f64 { + // For short positions: profit when premium decreases + // For long positions: profit when premium increases + let premium_change = self.current_premium - self.entry_premium; + let quantity = self.config.quantity as f64; + let lot_size = self.config.lot_size as f64; + -quantity * premium_change * lot_size + } +} + +/// Spread position state. +#[derive(Debug, Clone)] +struct SpreadPosition { + /// Individual leg positions. + pub legs: Vec, + /// Entry bar index. + pub entry_idx: usize, + /// Entry net premium (positive = credit, negative = debit). + pub entry_net_premium: f64, + /// Entry timestamp. + pub entry_time: i64, + /// Whether position is open. + pub is_open: bool, +} + +impl SpreadPosition { + fn new(legs: Vec, entry_idx: usize, entry_time: i64) -> Self { + let entry_net_premium: f64 = legs + .iter() + .map(|leg| leg.entry_premium * leg.config.quantity as f64 * leg.config.lot_size as f64) + .sum(); + + Self { legs, entry_idx, entry_net_premium, entry_time, is_open: true } + } + + /// Calculate total unrealized P&L across all legs. + fn total_unrealized_pnl(&self) -> f64 { + self.legs.iter().map(|leg| leg.unrealized_pnl()).sum() + } + + /// Update leg premiums. + fn update_premiums(&mut self, leg_premiums: &[f64]) { + for (leg, &premium) in self.legs.iter_mut().zip(leg_premiums.iter()) { + leg.current_premium = premium; + } + } + + /// Close the position and return P&L. + fn close(&mut self) -> f64 { + self.is_open = false; + self.total_unrealized_pnl() + } +} + +/// Spread backtest runner. +pub struct SpreadBacktest { + config: SpreadConfig, +} + +impl SpreadBacktest { + /// Create a new spread backtest. + pub fn new(config: SpreadConfig) -> Self { + Self { config } + } + + /// Run the spread backtest. + /// + /// # Arguments + /// * `timestamps` - Timestamp array + /// * `underlying_close` - Underlying close prices + /// * `legs_premiums` - Premium series for each leg (Vec of Vec) + /// * `entries` - Entry signals + /// * `exits` - Exit signals + /// + /// # Returns + /// Backtest result with metrics, trades, and equity curve + pub fn run( + &self, + timestamps: &[i64], + _underlying_close: &[f64], + legs_premiums: &[Vec], + entries: &[bool], + exits: &[bool], + ) -> BacktestResult { + let n = timestamps.len(); + + // Validate inputs + if legs_premiums.len() != self.config.leg_configs.len() { + return self.empty_result(n); + } + + for premiums in legs_premiums { + if premiums.len() != n { + return self.empty_result(n); + } + } + + let mut metrics = StreamingMetrics::with_initial_capital(self.config.base.initial_capital); + let mut equity_curve = Vec::with_capacity(n); + let mut drawdown_curve = Vec::with_capacity(n); + let mut returns = Vec::with_capacity(n); + let mut trades: Vec = Vec::new(); + let mut trade_id: u64 = 0; + + let mut cash = self.config.base.initial_capital; + let mut position: Option = None; + let mut prev_equity = cash; + + // Single-pass O(n) algorithm + for i in 0..n { + // Get current leg premiums + let current_premiums: Vec = legs_premiums.iter().map(|p| p[i]).collect(); + + // Update position premiums if open + if let Some(ref mut pos) = position { + pos.update_premiums(¤t_premiums); + } + + // Calculate unrealized P&L for exit checks + let unrealized_pnl = position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0); + + // Check if any leg has expired at this bar + let is_expiry = position.is_some() + && self.config.leg_expiry_timestamps.as_ref().map_or(false, |expiries| { + expiries.iter().any(|&exp_ts| timestamps[i] >= exp_ts) + }); + + // Check for exit signals or conditions + let should_exit = position.is_some() + && (exits[i] + || is_expiry + || self.check_max_loss(&position, unrealized_pnl) + || self.check_target_profit(&position, unrealized_pnl)); + + if should_exit { + if let Some(mut pos) = position.take() { + let pnl = pos.close(); + let fees = self.calculate_fees(&pos); + let net_pnl = pnl - fees; + + cash += net_pnl; + + // Record trade + trade_id += 1; + let exit_reason = if is_expiry { + ExitReason::Settlement + } else if exits[i] { + ExitReason::Signal + } else if self.check_max_loss(&Some(pos.clone()), pnl) { + ExitReason::StopLoss + } else { + ExitReason::TakeProfit + }; + + let entry_premium = pos.entry_net_premium; + let exit_premium: f64 = current_premiums + .iter() + .zip(self.config.leg_configs.iter()) + .map(|(&p, cfg)| p * cfg.quantity as f64 * cfg.lot_size as f64) + .sum(); + + trades.push(Trade { + id: trade_id, + symbol: "SPREAD".to_string(), + entry_idx: pos.entry_idx, + exit_idx: i, + entry_price: entry_premium, + exit_price: exit_premium, + size: 1.0, + direction: Direction::Long, // Spreads are treated as "long spread" + pnl: net_pnl, + return_pct: if entry_premium.abs() > 0.0 { + net_pnl / entry_premium.abs() * 100.0 + } else { + 0.0 + }, + entry_time: pos.entry_time, + exit_time: timestamps[i], + fees, + exit_reason, + }); + + metrics.record_trade( + net_pnl, + net_pnl / entry_premium.abs() * 100.0, + i - pos.entry_idx, + ); + } + } + + // Check for entry signals (don't re-enter after all legs expired) + let all_expired = + self.config.leg_expiry_timestamps.as_ref().map_or(false, |expiries| { + expiries.iter().all(|&exp_ts| timestamps[i] >= exp_ts) + }); + if position.is_none() && entries[i] && !all_expired { + let legs: Vec = self + .config + .leg_configs + .iter() + .zip(current_premiums.iter()) + .map(|(cfg, &premium)| LegPosition::new(cfg.clone(), premium, i)) + .collect(); + + let new_position = SpreadPosition::new(legs, i, timestamps[i]); + + // Calculate entry fees + let entry_fees = self.calculate_entry_fees(&new_position); + cash -= entry_fees; + + position = Some(new_position); + } + + // Update equity tracking + let equity = cash + position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0); + equity_curve.push(equity); + + let daily_return = + if prev_equity > 0.0 { (equity - prev_equity) / prev_equity } else { 0.0 }; + returns.push(daily_return); + prev_equity = equity; + + // Update drawdown + metrics.update_equity(equity); + drawdown_curve.push(metrics.current_drawdown_pct()); + } + + // Close any remaining open position at end + if let Some(mut pos) = position.take() { + let pnl = pos.close(); + let fees = self.calculate_fees(&pos); + cash += pnl - fees; + } + + // Finalize metrics + let final_metrics = metrics.finalize(self.config.base.initial_capital, cash, &returns); + + BacktestResult { metrics: final_metrics, equity_curve, drawdown_curve, trades, returns } + } + + /// Check if max loss threshold is hit. + fn check_max_loss(&self, _position: &Option, unrealized_pnl: f64) -> bool { + if let Some(max_loss) = self.config.max_loss { + if unrealized_pnl < -max_loss { + return true; + } + } + false + } + + /// Check if target profit threshold is hit. + fn check_target_profit(&self, _position: &Option, unrealized_pnl: f64) -> bool { + if let Some(target) = self.config.target_profit { + if unrealized_pnl > target { + return true; + } + } + false + } + + /// Calculate entry fees for a position. + fn calculate_entry_fees(&self, position: &SpreadPosition) -> f64 { + let total_premium: f64 = position + .legs + .iter() + .map(|leg| leg.entry_premium.abs() * leg.config.lot_size as f64) + .sum(); + total_premium * self.config.base.fees + } + + /// Calculate exit fees for a position. + fn calculate_fees(&self, position: &SpreadPosition) -> f64 { + let total_premium: f64 = position + .legs + .iter() + .map(|leg| leg.current_premium.abs() * leg.config.lot_size as f64) + .sum(); + total_premium * self.config.base.fees * 2.0 // Entry + Exit + } + + /// Create an empty result (used for validation failures). + fn empty_result(&self, n: usize) -> BacktestResult { + BacktestResult { + metrics: BacktestMetrics::default(), + equity_curve: vec![self.config.base.initial_capital; n], + drawdown_curve: vec![0.0; n], + trades: Vec::new(), + returns: vec![0.0; n], + } + } +} + +/// Convenience function to create a straddle spread config. +pub fn create_straddle_config( + base: BacktestConfig, + strike: f64, + lot_size: usize, + short: bool, +) -> SpreadConfig { + let quantity = if short { -1 } else { 1 }; + SpreadConfig { + base, + spread_type: SpreadType::Straddle, + leg_configs: vec![ + LegConfig::new(OptionType::Call, strike, quantity, lot_size), + LegConfig::new(OptionType::Put, strike, quantity, lot_size), + ], + ..Default::default() + } +} + +/// Convenience function to create a strangle spread config. +pub fn create_strangle_config( + base: BacktestConfig, + call_strike: f64, + put_strike: f64, + lot_size: usize, + short: bool, +) -> SpreadConfig { + let quantity = if short { -1 } else { 1 }; + SpreadConfig { + base, + spread_type: SpreadType::Strangle, + leg_configs: vec![ + LegConfig::new(OptionType::Call, call_strike, quantity, lot_size), + LegConfig::new(OptionType::Put, put_strike, quantity, lot_size), + ], + ..Default::default() + } +} + +/// Convenience function to create an iron condor spread config. +pub fn create_iron_condor_config( + base: BacktestConfig, + short_put_strike: f64, + long_put_strike: f64, + short_call_strike: f64, + long_call_strike: f64, + lot_size: usize, +) -> SpreadConfig { + SpreadConfig { + base, + spread_type: SpreadType::IronCondor, + leg_configs: vec![ + LegConfig::new(OptionType::Put, short_put_strike, -1, lot_size), + LegConfig::new(OptionType::Put, long_put_strike, 1, lot_size), + LegConfig::new(OptionType::Call, short_call_strike, -1, lot_size), + LegConfig::new(OptionType::Call, long_call_strike, 1, lot_size), + ], + ..Default::default() + } +} + +/// Convenience function to create a vertical spread config. +pub fn create_vertical_spread_config( + base: BacktestConfig, + option_type: OptionType, + long_strike: f64, + short_strike: f64, + lot_size: usize, +) -> SpreadConfig { + let spread_type = match option_type { + OptionType::Call => SpreadType::VerticalCall, + OptionType::Put => SpreadType::VerticalPut, + }; + + SpreadConfig { + base, + spread_type, + leg_configs: vec![ + LegConfig::new(option_type, long_strike, 1, lot_size), + LegConfig::new(option_type, short_strike, -1, lot_size), + ], + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::StopConfig; + use crate::core::types::TargetConfig; + + fn sample_data() -> (Vec, Vec, Vec>, Vec, Vec) { + let n = 20; + let timestamps: Vec = (0..n as i64).collect(); + let underlying: Vec = (100..120).map(|x| x as f64).collect(); + + // Call and Put premiums + let call_premiums: Vec = (0..n).map(|i| 5.0 + (i as f64 * 0.2)).collect(); + let put_premiums: Vec = (0..n).map(|i| 5.0 - (i as f64 * 0.1)).collect(); + + let legs_premiums = vec![call_premiums, put_premiums]; + + let entries = vec![ + false, true, false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + ]; + let exits = vec![ + false, false, false, false, false, false, false, false, false, true, false, false, + false, false, false, false, false, false, false, false, + ]; + + (timestamps, underlying, legs_premiums, entries, exits) + } + + #[test] + fn test_straddle_backtest() { + let base_config = BacktestConfig { + initial_capital: 100_000.0, + fees: 0.001, + slippage: 0.0, + stop: StopConfig::None, + target: TargetConfig::None, + upon_bar_close: true, + }; + + let config = create_straddle_config(base_config, 100.0, 50, true); + let backtest = SpreadBacktest::new(config); + + let (timestamps, underlying, legs_premiums, entries, exits) = sample_data(); + + let result = backtest.run(×tamps, &underlying, &legs_premiums, &entries, &exits); + + assert_eq!(result.trades.len(), 1); + assert!(result.equity_curve.len() == timestamps.len()); + } + + #[test] + fn test_iron_condor_backtest() { + let base_config = BacktestConfig::default(); + + let config = create_iron_condor_config( + base_config, + 95.0, // short put + 90.0, // long put + 105.0, // short call + 110.0, // long call + 50, + ); + + let backtest = SpreadBacktest::new(config); + + let n = 20; + let timestamps: Vec = (0..n as i64).collect(); + let underlying: Vec = vec![100.0; n]; + + // Four legs: short put, long put, short call, long call + let legs_premiums = vec![ + vec![3.0; n], // short put + vec![1.5; n], // long put + vec![3.0; n], // short call + vec![1.5; n], // long call + ]; + + let mut entries = vec![false; n]; + entries[1] = true; + + let mut exits = vec![false; n]; + exits[15] = true; + + let result = backtest.run(×tamps, &underlying, &legs_premiums, &entries, &exits); + + assert_eq!(result.trades.len(), 1); + } +} diff --git a/src/strategies/tick.rs b/src/strategies/tick.rs new file mode 100644 index 0000000..1ef6335 --- /dev/null +++ b/src/strategies/tick.rs @@ -0,0 +1,359 @@ +//! Tick-level backtest implementation. +//! +//! Accepts raw tick arrays (ltp, bid, ask, per-tick buy/sell qty deltas) plus +//! parallel entry/exit signal arrays, then simulates each trade to +//! stop-loss / take-profit / max-hold-time exit at full tick resolution. +//! +//! This is the right path for intraday options momentum strategies where the +//! exact fill tick matters. Do not resample to bars before calling this — +//! bar resampling discards intra-bar path information and makes scalping +//! strategies unbacktestable. + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, ExitReason, Price, TickData, Timestamp, Trade, +}; +use crate::portfolio::engine::compute_backtest_metrics; + +/// Configuration specific to tick backtests. +#[derive(Debug, Clone)] +pub struct TickBacktestConfig { + /// Shared execution config (capital, fees, slippage). + pub base: BacktestConfig, + /// Stop-loss as percentage of entry price (e.g. 5.0 = 5%). + pub stop_loss_pct: f64, + /// Take-profit as percentage of entry price (e.g. 10.0 = 10%). + pub take_profit_pct: f64, + /// Maximum hold time in seconds. 0 = no time limit. + pub max_hold_seconds: u64, + /// Minimum ticks between entries (cooldown). Prevents overlapping positions. + pub entry_cooldown_ticks: usize, + /// Maximum trades to simulate (bounds runtime for large windows). + pub max_trades: usize, +} + +impl Default for TickBacktestConfig { + fn default() -> Self { + Self { + base: BacktestConfig::default(), + stop_loss_pct: 5.0, + take_profit_pct: 10.0, + max_hold_seconds: 1800, + entry_cooldown_ticks: 10, + max_trades: 50, + } + } +} + +/// Tick-level backtest runner. +pub struct TickBacktest { + config: TickBacktestConfig, +} + +impl TickBacktest { + pub fn new(config: TickBacktestConfig) -> Self { + Self { config } + } + + /// Run the tick backtest. + /// + /// `ticks` — raw tick data (ltp, bid, ask, per-tick qty deltas) + /// `entries` — parallel bool array: true at ticks where a new long entry is allowed + /// `exits` — parallel bool array: true at ticks where an open position must close + /// `symbol` — instrument label used in trade records + pub fn run( + &self, + ticks: &TickData, + entries: &[bool], + exits: &[bool], + symbol: &str, + ) -> BacktestResult { + let n = ticks.len(); + assert_eq!(n, entries.len(), "ticks and entries must have same length"); + assert_eq!(n, exits.len(), "ticks and exits must have same length"); + + let slippage_frac = self.config.base.slippage; // e.g. 0.0005 = 0.05% + let fee_frac = self.config.base.fees; // e.g. 0.001 = 0.1% + let stop_frac = self.config.stop_loss_pct / 100.0; + let target_frac = self.config.take_profit_pct / 100.0; + let max_hold_ns: i64 = self.config.max_hold_seconds as i64 * 1_000_000_000; + + let mut trades: Vec = Vec::new(); + let mut trade_id: u64 = 0; + + // Position state + let mut in_position = false; + let mut entry_idx: usize = 0; + let mut entry_price: Price = 0.0; + let mut entry_time: Timestamp = 0; + let mut stop_level: Price = 0.0; + let mut target_level: Price = 0.0; + let mut entry_fees: f64 = 0.0; + let mut cooldown_until: usize = 0; + + for i in 0..n { + let ltp = ticks.ltp[i]; + let bid = if ticks.bid[i] > 0.0 { ticks.bid[i] } else { ltp }; + let ask = if ticks.ask[i] > 0.0 { ticks.ask[i] } else { ltp }; + let ts = ticks.timestamps[i]; + + if in_position { + // Check time exit first (hard deadline) + let time_exit = max_hold_ns > 0 && (ts - entry_time) >= max_hold_ns; + + // Check explicit exit signal + let signal_exit = exits[i]; + + // Check stop and target against ltp (tick-exact, no OHLC lookahead) + let stop_hit = ltp <= stop_level; + let target_hit = ltp >= target_level; + + let (exit_price, reason) = if stop_hit { + // Fill at stop level (not ltp — avoid worse-than-stop fills) + let fill = stop_level * (1.0 - slippage_frac); + (fill, ExitReason::StopLoss) + } else if target_hit { + let fill = target_level * (1.0 - slippage_frac); + (fill, ExitReason::TakeProfit) + } else if time_exit || signal_exit { + let fill = bid * (1.0 - slippage_frac); + let reason = if time_exit { ExitReason::TimeExit } else { ExitReason::Signal }; + (fill, reason) + } else if i == n - 1 { + // End of data — force close at bid + let fill = bid * (1.0 - slippage_frac); + (fill, ExitReason::EndOfData) + } else { + continue; + }; + + let exit_fees = exit_price * fee_frac; + let gross_pnl = (exit_price - entry_price) * 1.0; // qty=1; caller scales by lot_size + let net_pnl = gross_pnl - entry_fees - exit_fees; + let return_pct = net_pnl / entry_price * 100.0; + + trades.push(Trade { + id: trade_id, + symbol: symbol.to_string(), + entry_idx, + exit_idx: i, + entry_price, + exit_price, + size: 1.0, + direction: crate::core::types::Direction::Long, + pnl: net_pnl, + return_pct, + entry_time, + exit_time: ts, + fees: entry_fees + exit_fees, + exit_reason: reason, + }); + + trade_id += 1; + in_position = false; + cooldown_until = i + self.config.entry_cooldown_ticks; + + if trades.len() >= self.config.max_trades { + break; + } + } else { + // Not in position — check for entry + if i < cooldown_until { + continue; + } + if !entries[i] { + continue; + } + if ask <= 0.0 { + continue; + } + + entry_price = ask * (1.0 + slippage_frac); + entry_fees = entry_price * fee_frac; + entry_idx = i; + entry_time = ts; + stop_level = entry_price * (1.0 - stop_frac); + target_level = entry_price * (1.0 + target_frac); + in_position = true; + } + } + + Self::build_result(trades, self.config.base.initial_capital, symbol) + } + + fn build_result(trades: Vec, initial_capital: f64, _symbol: &str) -> BacktestResult { + if trades.is_empty() { + let metrics = BacktestMetrics { + start_value: initial_capital, + end_value: initial_capital, + ..Default::default() + }; + return BacktestResult::new(metrics, vec![initial_capital], vec![0.0], vec![], vec![]); + } + + // Build per-trade equity and return curves (one point per trade close). + let mut equity = initial_capital; + let mut equity_curve = vec![initial_capital]; + let mut returns = Vec::with_capacity(trades.len()); + + for t in &trades { + let prev = *equity_curve.last().unwrap(); + equity += t.pnl; + equity_curve.push(equity); + let ret = if prev > 0.0 { (equity - prev) / prev } else { 0.0 }; + returns.push(ret); + } + + // Drawdown curve over equity points (percentage, positive = drawdown). + let mut peak = initial_capital; + let drawdown_curve: Vec = equity_curve + .iter() + .map(|&e| { + if e > peak { + peak = e; + } + if peak > 0.0 { (peak - e) / peak * 100.0 } else { 0.0 } + }) + .collect(); + + let metrics = + compute_backtest_metrics(&equity_curve, &drawdown_curve, &returns, &trades, initial_capital); + + BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::BacktestConfig; + + fn make_ticks(n: usize, base_price: f64, trend: f64) -> TickData { + let ltp: Vec = (0..n).map(|i| base_price + i as f64 * trend).collect(); + let bid: Vec = ltp.iter().map(|p| p - 0.5).collect(); + let ask: Vec = ltp.iter().map(|p| p + 0.5).collect(); + TickData { + timestamps: (0..n as i64).map(|i| i * 1_000_000_000).collect(), // 1s apart + ltp, + bid, + ask, + buy_qty_delta: vec![100.0; n], + sell_qty_delta: vec![80.0; n], + oi: vec![0.0; n], + } + } + + #[test] + fn test_target_hit() { + // 100 ticks trending up — entry at tick 0, target should be hit + let ticks = make_ticks(100, 100.0, 0.5); // price goes 100 → 149.5 + let mut entries = vec![false; 100]; + entries[0] = true; + let exits = vec![false; 100]; + + let config = TickBacktestConfig { + base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() }, + stop_loss_pct: 5.0, + take_profit_pct: 10.0, + max_hold_seconds: 0, // no time limit + entry_cooldown_ticks: 5, + max_trades: 10, + }; + + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &entries, &exits, "TEST"); + + assert_eq!(result.trades.len(), 1); + assert_eq!(result.trades[0].exit_reason, ExitReason::TakeProfit); + assert!(result.trades[0].pnl > 0.0); + } + + #[test] + fn test_stop_hit() { + // 100 ticks trending down — entry at tick 0, stop should be hit + let ticks = make_ticks(100, 100.0, -0.5); // price goes 100 → 50.5 + let mut entries = vec![false; 100]; + entries[0] = true; + let exits = vec![false; 100]; + + let config = TickBacktestConfig { + base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() }, + stop_loss_pct: 5.0, + take_profit_pct: 20.0, + max_hold_seconds: 0, + entry_cooldown_ticks: 5, + max_trades: 10, + }; + + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &entries, &exits, "TEST"); + + assert_eq!(result.trades.len(), 1); + assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss); + assert!(result.trades[0].pnl < 0.0); + } + + #[test] + fn test_time_exit() { + // Flat price — neither stop nor target hit, time exit should fire + let ticks = make_ticks(200, 100.0, 0.0); + let mut entries = vec![false; 200]; + entries[0] = true; + let exits = vec![false; 200]; + + let config = TickBacktestConfig { + base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() }, + stop_loss_pct: 50.0, // very wide, won't hit + take_profit_pct: 50.0, + max_hold_seconds: 10, // 10 ticks at 1s each + entry_cooldown_ticks: 5, + max_trades: 10, + }; + + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &entries, &exits, "TEST"); + + assert_eq!(result.trades.len(), 1); + assert_eq!(result.trades[0].exit_reason, ExitReason::TimeExit); + } + + #[test] + fn test_multiple_trades_with_cooldown() { + let ticks = make_ticks(200, 100.0, 0.2); + // Entry every 20 ticks + let entries: Vec = (0..200).map(|i| i % 20 == 0).collect(); + let exits = vec![false; 200]; + + let config = TickBacktestConfig { + base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() }, + stop_loss_pct: 5.0, + take_profit_pct: 10.0, + max_hold_seconds: 0, + entry_cooldown_ticks: 5, + max_trades: 20, + }; + + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &entries, &exits, "TEST"); + + assert!(result.trades.len() > 1); + assert!(result.metrics.total_trades > 1); + } + + #[test] + fn test_empty_ticks_returns_empty_result() { + let ticks = TickData { + timestamps: vec![], + ltp: vec![], + bid: vec![], + ask: vec![], + buy_qty_delta: vec![], + sell_qty_delta: vec![], + oi: vec![], + }; + let config = TickBacktestConfig::default(); + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &[], &[], "TEST"); + assert_eq!(result.trades.len(), 0); + assert_eq!(result.metrics.total_trades, 0); + } +} diff --git a/test_with_mt5.py b/test_with_mt5.py new file mode 100644 index 0000000..cea78a6 --- /dev/null +++ b/test_with_mt5.py @@ -0,0 +1,1090 @@ +""" +RaptorBT + Mt5Bridge 集成测试 + +从 Mt5Bridge API 拉取真实 K 线数据,用 RaptorBT 跑回测。 + +运行前确保: + pip install raptorbt requests numpy pandas + +运行: + python test_with_mt5.py +""" + +import os +import requests +import numpy as np +import pandas as pd +import raptorbt +from datetime import datetime, timedelta, timezone + +BRIDGE = "http://61.164.252.86:13485" +API_KEY = "UiHMqtaYLZzwBdcuS4RFmEGhgDO8N2eI" + +SYMBOL = "XAUUSD" +TIMEFRAME = "TIMEFRAME_H1" +BARS = 500 + + +def api_get(path, params=None): + resp = requests.get( + f"{BRIDGE}{path}", + params=params, + headers={"X-API-Key": API_KEY}, + timeout=15, + ) + resp.raise_for_status() + return resp.json() + + +def check_health(): + print("=" * 60) + print("1. 健康检查") + print("=" * 60) + data = api_get("/health") + print(f" 状态: {data.get('status')}") + print(f" MT5 连接: {data.get('mt5_connected')}") + if not data.get("mt5_connected"): + print(" ⚠️ MT5 未连接,后续可能失败") + return data + + +def fetch_account(): + print("\n" + "=" * 60) + print("2. 账户信息") + print("=" * 60) + data = api_get("/account")["data"][0] + print(f" 余额: {data['balance']:.2f} {data['currency']}") + print(f" 净值: {data['equity']:.2f}") + print(f" 浮动盈亏: {data['profit']:.2f}") + print(f" 杠杆: 1:{data['leverage']}") + return data + + +def fetch_klines(): + print("\n" + "=" * 60) + print(f"3. 拉取 K 线数据 ({SYMBOL}, {TIMEFRAME}, 最近 {BARS} 根)") + print("=" * 60) + + date_to = datetime.now(timezone.utc).strftime("%Y-%m-%d") + date_from = (datetime.now(timezone.utc) - timedelta(days=BARS // 24 + 30)).strftime("%Y-%m-%d") + + data = api_get("/rates/from-date", params={ + "symbol": SYMBOL, + "timeframe": TIMEFRAME, + "date_from": date_from, + "date_to": date_to, + }) + + rows = data.get("data", []) + if not rows: + print(" ⚠️ 没有拉到数据,尝试 /rates/from-pos ...") + data = api_get("/rates/from-pos", params={ + "symbol": SYMBOL, + "timeframe": TIMEFRAME, + "start_pos": 0, + "count": BARS, + }) + rows = data.get("data", []) + + if not rows: + raise RuntimeError("两种方式都没拉到 K 线,检查品种名或 MT5 连接") + + df = pd.DataFrame(rows) + df["time"] = pd.to_datetime(df["time"]) + df = df.sort_values("time").reset_index(drop=True) + + print(f" 拉到 {len(df)} 根 K 线") + print(f" 时间范围: {df['time'].iloc[0]} ~ {df['time'].iloc[-1]}") + print(f" Close 范围: {df['close'].min():.2f} ~ {df['close'].max():.2f}") + return df + + +def run_backtest(df): + print("\n" + "=" * 60) + print("4. RaptorBT 回测 (SMA 交叉策略)") + print("=" * 60) + + close = df["close"].values.astype(np.float64) + open_ = df["open"].values.astype(np.float64) + high = df["high"].values.astype(np.float64) + low = df["low"].values.astype(np.float64) + volume = df["tick_volume"].values.astype(np.float64) + timestamps = df["time"].values.astype("int64") + + sma_fast = raptorbt.sma(close, period=10) + sma_slow = raptorbt.sma(close, period=20) + + entries = (sma_fast > sma_slow) & np.roll(sma_fast <= sma_slow, 1) + exits = (sma_fast < sma_slow) & np.roll(sma_fast >= sma_slow, 1) + entries[:20] = False + exits[:20] = False + + entries = entries.astype(bool) + exits = exits.astype(bool) + + n_entries = int(entries.sum()) + n_exits = int(exits.sum()) + print(f" SMA(10)/SMA(20) 交叉信号: {n_entries} 次入场, {n_exits} 次出场") + + config = raptorbt.PyBacktestConfig( + initial_capital=100000.0, + fees=0.001, + slippage=0.0005, + ) + config.set_fixed_stop(0.02) + config.set_fixed_target(0.04) + + result = raptorbt.run_single_backtest( + timestamps=timestamps, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + entries=entries, + exits=exits, + direction=1, + weight=1.0, + symbol=SYMBOL, + config=config, + ) + + m = result.metrics + print(f"\n {'─' * 40}") + print(f" 回测结果") + print(f" {'─' * 40}") + print(f" 总收益率: {m.total_return_pct:>10.2f} %") + print(f" 夏普比率: {m.sharpe_ratio:>10.2f}") + print(f" 索提诺比率: {m.sortino_ratio:>10.2f}") + print(f" 卡玛比率: {m.calmar_ratio:>10.2f}") + print(f" 最大回撤: {m.max_drawdown_pct:>10.2f} %") + print(f" 总交易数: {m.total_trades:>10d}") + print(f" 胜率: {m.win_rate_pct:>10.1f} %") + print(f" 盈利因子: {m.profit_factor:>10.2f}") + print(f" 平均交易收益: {m.avg_trade_return_pct:>10.2f} %") + print(f" 平均盈利: {m.avg_win_pct:>10.2f} %") + print(f" 平均亏损: {m.avg_loss_pct:>10.2f} %") + print(f" 最佳交易: {m.best_trade_pct:>10.2f} %") + print(f" 最差交易: {m.worst_trade_pct:>10.2f} %") + print(f" 期望值: {m.expectancy:>10.2f}") + print(f" SQN: {m.sqn:>10.2f}") + print(f" 总手续费: {m.total_fees_paid:>10.2f}") + print(f" 市场暴露: {m.exposure_pct:>10.1f} %") + + trades = result.trades() + if trades: + print(f"\n 最近 5 笔交易:") + print(f" {'ID':>6} {'方向':>4} {'入场价':>12} {'出场价':>12} {'收益%':>10} {'出场原因':>12}") + for t in trades[-5:]: + direction = "多" if t.direction == 1 else "空" + print(f" {t.id:>6} {direction:>4} {t.entry_price:>12.5f} {t.exit_price:>12.5f} {t.return_pct:>10.2f} {t.exit_reason:>12}") + + return result + + +def run_multi_indicator_backtest(df): + print("\n" + "=" * 60) + print("5. 多策略回测 (SMA交叉 + RSI均值回归)") + print("=" * 60) + + close = df["close"].values.astype(np.float64) + open_ = df["open"].values.astype(np.float64) + high = df["high"].values.astype(np.float64) + low = df["low"].values.astype(np.float64) + volume = df["tick_volume"].values.astype(np.float64) + timestamps = df["time"].values.astype("int64") + + sma_fast = raptorbt.sma(close, period=10) + sma_slow = raptorbt.sma(close, period=20) + rsi = raptorbt.rsi(close, period=14) + + entries_sma = ((sma_fast > sma_slow) & np.roll(sma_fast <= sma_slow, 1)).astype(bool) + exits_sma = ((sma_fast < sma_slow) & np.roll(sma_fast >= sma_slow, 1)).astype(bool) + + entries_rsi = (rsi < 30).astype(bool) + exits_rsi = (rsi > 70).astype(bool) + + entries_sma[:20] = False + exits_sma[:20] = False + entries_rsi[:14] = False + exits_rsi[:14] = False + + strategies = [ + (entries_sma, exits_sma, 1, 0.6, "SMA_Cross"), + (entries_rsi, exits_rsi, 1, 0.4, "RSI_MeanRev"), + ] + + config = raptorbt.PyBacktestConfig( + initial_capital=100000.0, + fees=0.001, + slippage=0.0005, + ) + config.set_trailing_stop(0.03) + + result = raptorbt.run_multi_backtest( + timestamps=timestamps, + open=open_, + high=high, + low=low, + close=close, + volume=volume, + strategies=strategies, + config=config, + combine_mode="any", + ) + + m = result.metrics + print(f" 总收益率: {m.total_return_pct:.2f}% 夏普: {m.sharpe_ratio:.2f} " + f"最大回撤: {m.max_drawdown_pct:.2f}% 交易数: {m.total_trades} 胜率: {m.win_rate_pct:.1f}%") + return result + + +def show_indicators(df): + print("\n" + "=" * 60) + print("6. 内置指标演示") + print("=" * 60) + + close = df["close"].values.astype(np.float64) + high = df["high"].values.astype(np.float64) + low = df["low"].values.astype(np.float64) + volume = df["tick_volume"].values.astype(np.float64) + + indicators = { + "SMA(20)": raptorbt.sma(close, period=20), + "EMA(20)": raptorbt.ema(close, period=20), + "RSI(14)": raptorbt.rsi(close, period=14), + "ATR(14)": raptorbt.atr(high, low, close, period=14), + "ADX(14)": raptorbt.adx(high, low, close, period=14), + "VWAP": raptorbt.vwap(high, low, close, volume), + } + + macd_line, signal_line, hist = raptorbt.macd(close, 12, 26, 9) + indicators["MACD_Line"] = macd_line + indicators["MACD_Signal"] = signal_line + + stoch_k, stoch_d = raptorbt.stochastic(high, low, close, k_period=14, d_period=3) + indicators["Stoch_K"] = stoch_k + indicators["Stoch_D"] = stoch_d + + upper, middle, lower = raptorbt.bollinger_bands(close, period=20, std_dev=2.0) + indicators["BB_Upper"] = upper + indicators["BB_Lower"] = lower + + supertrend_val, supertrend_dir = raptorbt.supertrend(high, low, close, period=10, multiplier=3.0) + indicators["Supertrend"] = supertrend_val + + indicators["Rolling_Min(20)"] = raptorbt.rolling_min(low, period=20) + indicators["Rolling_Max(20)"] = raptorbt.rolling_max(high, period=20) + + last_idx = -1 + print(f"\n 最新一根 K 线的指标值:") + print(f" {'指标':<20} {'值':>15}") + print(f" {'─' * 40}") + for name, arr in indicators.items(): + val = arr[last_idx] + if np.isnan(val): + print(f" {name:<20} {'NaN':>15}") + else: + print(f" {name:<20} {val:>15.4f}") + + +def run_atr_stop_backtest(df): + print("\n" + "=" * 60) + print("7. ATR 动态止损 + 风险回报比止盈") + print("=" * 60) + + close = df["close"].values.astype(np.float64) + open_ = df["open"].values.astype(np.float64) + high = df["high"].values.astype(np.float64) + low = df["low"].values.astype(np.float64) + volume = df["tick_volume"].values.astype(np.float64) + timestamps = df["time"].values.astype("int64") + + sma_fast = raptorbt.sma(close, period=10) + sma_slow = raptorbt.sma(close, period=20) + + entries = ((sma_fast > sma_slow) & np.roll(sma_fast <= sma_slow, 1)).astype(bool) + exits = ((sma_fast < sma_slow) & np.roll(sma_fast >= sma_slow, 1)).astype(bool) + entries[:20] = False + exits[:20] = False + + config = raptorbt.PyBacktestConfig( + initial_capital=100000.0, + fees=0.001, + slippage=0.0005, + ) + config.set_atr_stop(multiplier=2.0, period=14) + config.set_risk_reward_target(ratio=2.0) + + result = raptorbt.run_single_backtest( + timestamps=timestamps, + open=open_, high=high, low=low, close=close, + volume=volume, + entries=entries, exits=exits, + direction=1, weight=1.0, symbol=SYMBOL, + config=config, + ) + + m = result.metrics + print(f" 策略: SMA 交叉 + 2×ATR 止损 + 2:1 风险回报止盈") + print(f" 总收益率: {m.total_return_pct:.2f}% 夏普: {m.sharpe_ratio:.2f} " + f"最大回撤: {m.max_drawdown_pct:.2f}% 交易数: {m.total_trades} 胜率: {m.win_rate_pct:.1f}%") + + trades = result.trades() + exit_reasons = {} + for t in trades: + r = t.exit_reason + exit_reasons[r] = exit_reasons.get(r, 0) + 1 + print(f" 出场原因分布: {exit_reasons}") + return result + + +def run_basket_backtest(df): + print("\n" + "=" * 60) + print("8. 篮子回测 (多标的同步信号)") + print("=" * 60) + + other_symbols = ["XAUUSD", "EURUSD", "GBPUSD"] + dfs = {} + for sym in other_symbols: + try: + date_to = datetime.now(timezone.utc).strftime("%Y-%m-%d") + date_from = (datetime.now(timezone.utc) - timedelta(days=BARS // 24 + 30)).strftime("%Y-%m-%d") + data = api_get("/rates/from-date", params={ + "symbol": sym, + "timeframe": TIMEFRAME, + "date_from": date_from, + "date_to": date_to, + }) + rows = data.get("data", []) + if not rows: + print(f" ⚠️ {sym} 无数据,跳过") + continue + sdf = pd.DataFrame(rows) + sdf["time"] = pd.to_datetime(sdf["time"]) + sdf = sdf.sort_values("time").reset_index(drop=True) + dfs[sym] = sdf + print(f" {sym}: 拉到 {len(sdf)} 根 K 线") + except Exception as e: + print(f" ⚠️ {sym} 拉取失败: {e}") + + if len(dfs) < 2: + print(" ⚠️ 可用品种不足 2 个,跳过篮子回测") + return None + + min_len = min(len(d) for d in dfs.values()) + instruments = [] + instrument_configs = {} + per_capital = 100000.0 / len(dfs) + + for i, (sym, sdf) in enumerate(dfs.items()): + c = sdf["close"].values.astype(np.float64)[:min_len] + o = sdf["open"].values.astype(np.float64)[:min_len] + h = sdf["high"].values.astype(np.float64)[:min_len] + l = sdf["low"].values.astype(np.float64)[:min_len] + v = sdf["tick_volume"].values.astype(np.float64)[:min_len] + ts = sdf["time"].values.astype("int64")[:min_len] + + sma_f = raptorbt.sma(c, period=10) + sma_s = raptorbt.sma(c, period=20) + ent = ((sma_f > sma_s) & np.roll(sma_f <= sma_s, 1)).astype(bool) + ext = ((sma_f < sma_s) & np.roll(sma_f >= sma_s, 1)).astype(bool) + ent[:20] = False + ext[:20] = False + + instruments.append((ts, o, h, l, c, v, ent, ext, 1, 1.0 / len(dfs), sym)) + inst_cfg = raptorbt.PyInstrumentConfig(lot_size=0.01, alloted_capital=per_capital) + instrument_configs[sym] = inst_cfg + + config = raptorbt.PyBacktestConfig( + initial_capital=100000.0, + fees=0.001, + slippage=0.0005, + ) + config.set_trailing_stop(0.03) + + for mode in ["any", "all"]: + result = raptorbt.run_basket_backtest( + instruments=instruments, + config=config, + sync_mode=mode, + instrument_configs=instrument_configs, + ) + m = result.metrics + print(f" sync_mode={mode:<8} → 收益: {m.total_return_pct:>7.2f}% " + f"夏普: {m.sharpe_ratio:>6.2f} 回撤: {m.max_drawdown_pct:>6.2f}% " + f"交易: {m.total_trades:>3d} 胜率: {m.win_rate_pct:>5.1f}%") + return result + + +def run_pairs_backtest(df): + print("\n" + "=" * 60) + print("9. 配对交易 (EURUSD vs GBPUSD)") + print("=" * 60) + + pairs = [("EURUSD", "GBPUSD"), ("EURUSD", "USDJPY")] + for leg1_sym, leg2_sym in pairs: + try: + date_to = datetime.now(timezone.utc).strftime("%Y-%m-%d") + date_from = (datetime.now(timezone.utc) - timedelta(days=BARS // 24 + 30)).strftime("%Y-%m-%d") + + data1 = api_get("/rates/from-date", params={ + "symbol": leg1_sym, "timeframe": TIMEFRAME, + "date_from": date_from, "date_to": date_to, + }) + data2 = api_get("/rates/from-date", params={ + "symbol": leg2_sym, "timeframe": TIMEFRAME, + "date_from": date_from, "date_to": date_to, + }) + + rows1 = data1.get("data", []) + rows2 = data2.get("data", []) + if not rows1 or not rows2: + print(f" ⚠️ {leg1_sym}/{leg2_sym} 数据不足,跳过") + continue + + df1 = pd.DataFrame(rows1) + df2 = pd.DataFrame(rows2) + df1["time"] = pd.to_datetime(df1["time"]) + df2["time"] = pd.to_datetime(df2["time"]) + df1 = df1.sort_values("time").reset_index(drop=True) + df2 = df2.sort_values("time").reset_index(drop=True) + + min_len = min(len(df1), len(df2)) + c1 = df1["close"].values.astype(np.float64)[:min_len] + c2 = df2["close"].values.astype(np.float64)[:min_len] + + spread = c1 - c2 + spread_sma = pd.Series(spread).rolling(20).mean().values + spread_std = pd.Series(spread).rolling(20).std().values + zscore = (spread - spread_sma) / np.where(spread_std > 0, spread_std, 1e-10) + + entries = (zscore < -2.0).astype(bool) + exits = (np.abs(zscore) < 0.5).astype(bool) + entries[:20] = False + exits[:20] = False + + config = raptorbt.PyBacktestConfig( + initial_capital=100000.0, + fees=0.001, + slippage=0.0005, + ) + config.set_fixed_stop(0.03) + + result = raptorbt.run_pairs_backtest( + leg1_timestamps=df1["time"].values.astype("int64")[:min_len], + leg1_open=df1["open"].values.astype(np.float64)[:min_len], + leg1_high=df1["high"].values.astype(np.float64)[:min_len], + leg1_low=df1["low"].values.astype(np.float64)[:min_len], + leg1_close=c1, + leg1_volume=df1["tick_volume"].values.astype(np.float64)[:min_len], + leg2_timestamps=df2["time"].values.astype("int64")[:min_len], + leg2_open=df2["open"].values.astype(np.float64)[:min_len], + leg2_high=df2["high"].values.astype(np.float64)[:min_len], + leg2_low=df2["low"].values.astype(np.float64)[:min_len], + leg2_close=c2, + leg2_volume=df2["tick_volume"].values.astype(np.float64)[:min_len], + entries=entries, + exits=exits, + direction=1, + symbol=f"{leg1_sym}_{leg2_sym}", + config=config, + hedge_ratio=1.0, + ) + + m = result.metrics + print(f" {leg1_sym}/{leg2_sym} 配对 (Z-score 均值回归):") + print(f" 收益: {m.total_return_pct:.2f}% 夏普: {m.sharpe_ratio:.2f} " + f"回撤: {m.max_drawdown_pct:.2f}% 交易: {m.total_trades} 胜率: {m.win_rate_pct:.1f}%") + except Exception as e: + print(f" ⚠️ {leg1_sym}/{leg2_sym} 配对失败: {e}") + return None + + +def run_monte_carlo(result1, result2): + print("\n" + "=" * 60) + print("10. 蒙特卡洛组合模拟") + print("=" * 60) + + returns1 = result1.returns() + returns2 = result2.returns() + min_len = min(len(returns1), len(returns2)) + + r1 = returns1[:min_len] + r2 = returns2[:min_len] + + mask = ~(np.isnan(r1) | np.isnan(r2)) + r1 = r1[mask] + r2 = r2[mask] + + if len(r1) < 50: + print(" ⚠️ 有效收益率数据不足,跳过蒙特卡洛模拟") + return + + corr = np.corrcoef(r1, r2)[0, 1] + correlation_matrix = [ + np.array([1.0, corr]), + np.array([corr, 1.0]), + ] + + print(f" 策略 1 (SMA交叉) vs 策略 2 (多策略) 收益率相关系数: {corr:.3f}") + + for n_sim in [1000, 10000]: + mc_result = raptorbt.simulate_portfolio_mc( + returns=[r1, r2], + weights=np.array([0.6, 0.4]), + correlation_matrix=correlation_matrix, + initial_value=100000.0, + n_simulations=n_sim, + horizon_days=252, + seed=42, + ) + + print(f"\n 模拟次数: {n_sim:,}") + print(f" {'─' * 40}") + print(f" 预期收益: {mc_result['expected_return']:>10.2f} %") + print(f" 亏损概率: {mc_result['probability_of_loss']:>10.2%}") + print(f" VaR (95%): {mc_result['var_95']:>10.2f} %") + print(f" CVaR (95%): {mc_result['cvar_95']:>10.2f} %") + + print(f" 分位数路径终值:") + for pct, path in mc_result["percentile_paths"]: + print(f" P{pct:>2.0f}: {path[-1]:>12,.2f}") + + +def export_results(result, df, strategy_name="sma_cross"): + print("\n" + "=" * 60) + print("11. 导出回测结果") + print("=" * 60) + + output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backtest_output") + os.makedirs(output_dir, exist_ok=True) + + trades = result.trades() + if trades: + rows = [] + for t in trades: + rows.append({ + "trade_id": t.id, + "symbol": t.symbol, + "direction": "Long" if t.direction == 1 else "Short", + "entry_idx": t.entry_idx, + "exit_idx": t.exit_idx, + "entry_time": df["time"].iloc[t.entry_idx] if t.entry_idx < len(df) else "", + "exit_time": df["time"].iloc[t.exit_idx] if t.exit_idx < len(df) else "", + "entry_price": t.entry_price, + "exit_price": t.exit_price, + "size": t.size, + "pnl": t.pnl, + "return_pct": t.return_pct, + "fees": t.fees, + "exit_reason": t.exit_reason, + }) + trades_df = pd.DataFrame(rows) + trades_path = os.path.join(output_dir, f"{strategy_name}_trades.csv") + trades_df.to_csv(trades_path, index=False, encoding="utf-8-sig") + print(f" 交易记录 → {trades_path} ({len(trades_df)} 笔)") + + equity = result.equity_curve() + drawdown = result.drawdown_curve() + returns = result.returns() + + curve_df = pd.DataFrame({ + "time": df["time"].values[:len(equity)], + "equity": equity, + "drawdown": drawdown, + "returns": returns, + }) + curve_path = os.path.join(output_dir, f"{strategy_name}_curves.csv") + curve_df.to_csv(curve_path, index=False, encoding="utf-8-sig") + print(f" 权益曲线 → {curve_path} ({len(curve_df)} 根)") + + m = result.metrics + metrics_dict = m.to_dict() + metrics_dict["total_trades"] = m.total_trades + metrics_dict["total_closed_trades"] = m.total_closed_trades + metrics_dict["total_open_trades"] = m.total_open_trades + metrics_dict["winning_trades"] = m.winning_trades + metrics_dict["losing_trades"] = m.losing_trades + metrics_dict["max_consecutive_wins"] = m.max_consecutive_wins + metrics_dict["max_consecutive_losses"] = m.max_consecutive_losses + metrics_dict["avg_holding_period"] = m.avg_holding_period + metrics_dict["avg_winning_duration"] = m.avg_winning_duration + metrics_dict["avg_losing_duration"] = m.avg_losing_duration + metrics_dict["start_value"] = m.start_value + metrics_dict["end_value"] = m.end_value + metrics_dict["total_fees_paid"] = m.total_fees_paid + metrics_dict["open_trade_pnl"] = m.open_trade_pnl + metrics_dict["exposure_pct"] = m.exposure_pct + metrics_dict["payoff_ratio"] = m.payoff_ratio + metrics_dict["recovery_factor"] = m.recovery_factor + metrics_dict["omega_ratio"] = m.omega_ratio + + metrics_df = pd.DataFrame(list(metrics_dict.items()), columns=["metric", "value"]) + metrics_path = os.path.join(output_dir, f"{strategy_name}_metrics.csv") + metrics_df.to_csv(metrics_path, index=False, encoding="utf-8-sig") + print(f" 绩效指标 → {metrics_path} ({len(metrics_df)} 项)") + + print(f"\n 所有文件保存在: {output_dir}") + + +def test_ferro_indicators(df): + print("\n" + "=" * 60) + print("7. ferro-ta 新指标全量测试 (45+ 指标)") + print("=" * 60) + + close = df["close"].values.astype(np.float64) + open_ = df["open"].values.astype(np.float64) + high = df["high"].values.astype(np.float64) + low = df["low"].values.astype(np.float64) + volume = df["tick_volume"].values.astype(np.float64) + n = len(close) + errors = [] + tested = 0 + + def check(name, arr, expect_len=None, expect_finite=True, value_range=None, min_valid=1): + nonlocal tested + tested += 1 + if expect_len is not None and len(arr) != expect_len: + errors.append(f"{name}: 长度 {len(arr)} != 期望 {expect_len}") + return + valid = arr[~np.isnan(arr)] + if len(valid) < min_valid: + errors.append(f"{name}: 有效值仅 {len(valid)} 个 (最少需要 {min_valid})") + return + if expect_finite: + inf_count = np.isinf(valid).sum() + if inf_count > 0: + errors.append(f"{name}: 有 {inf_count} 个 inf 值") + if value_range and len(valid) > 0: + lo, hi = value_range + out_of_range = ((valid < lo) | (valid > hi)).sum() + if out_of_range > 0: + errors.append(f"{name}: {out_of_range} 个值超出 [{lo}, {hi}]") + last = arr[-1] + status = f"{last:.4f}" if not np.isnan(last) else "NaN" + print(f" {name:<25} 长度={len(arr):>4} 有效值={len(valid):>4} 最新={status}") + + # ── Overlap 均线类 ── + print("\n ── Overlap 均线类 ──") + check("WMA(20)", raptorbt.wma(close, period=20), n, min_valid=10) + check("DEMA(20)", raptorbt.dema(close, period=20), n, min_valid=10) + check("TEMA(20)", raptorbt.tema(close, period=20), n, min_valid=10) + check("KAMA(10)", raptorbt.kama(close, period=10), n, min_valid=10) + check("T3(20)", raptorbt.t3(close, period=20, vfactor=0.7), n, min_valid=10) + check("TRIMA(20)", raptorbt.trima(close, period=20), n, min_valid=10) + check("Midpoint(20)", raptorbt.midpoint(close, period=20), n, min_valid=10) + check("Midprice(20)", raptorbt.midprice(high, low, period=20), n, min_valid=10) + check("SAR(0.02,0.2)", raptorbt.sar(high, low, acceleration=0.02, maximum=0.2), n, min_valid=10) + + # ── Momentum 动量类 ── + print("\n ── Momentum 动量类 ──") + check("CCI(20)", raptorbt.cci(high, low, close, period=20), n, min_valid=10) + check("WillR(14)", raptorbt.willr(high, low, close, period=14), n, value_range=(-101, 1), min_valid=10) + check("ROC(10)", raptorbt.roc(close, period=10), n, min_valid=10) + check("MOM(10)", raptorbt.mom(close, period=10), n, min_valid=10) + check("CMO(14)", raptorbt.cmo(close, period=14), n, min_valid=10) + check("TRIX(12)", raptorbt.trix(close, period=12), n, min_valid=10) + check("UltOSC(7,14,28)", raptorbt.ultosc(high, low, close, period1=7, period2=14, period3=28), n, value_range=(-1, 101), min_valid=10) + check("AroonOsc(14)", raptorbt.aroonosc(high, low, period=14), n, value_range=(-101, 101), min_valid=10) + check("BOP", raptorbt.bop(open_, high, low, close), n, value_range=(-1.01, 1.01), min_valid=10) + check("Plus_DI(14)", raptorbt.plus_di(high, low, close, period=14), n, value_range=(-1, 101), min_valid=10) + check("Minus_DI(14)", raptorbt.minus_di(high, low, close, period=14), n, value_range=(-1, 101), min_valid=10) + + # ── 多输出动量指标 ── + print("\n ── 多输出动量指标 ──") + try: + stochrsi_k, stochrsi_d = raptorbt.stochrsi(close, timeperiod=14, fastk_period=5, fastd_period=3) + check("StochRSI_K", stochrsi_k, n, value_range=(-5, 105), min_valid=10) + check("StochRSI_D", stochrsi_d, n, value_range=(-5, 105), min_valid=10) + except Exception as e: + errors.append(f"StochRSI: {e}") + tested += 2 + + try: + adx_val, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=14) + check("ADX_all(14)", adx_val, n, value_range=(-1, 101), min_valid=10) + check("ADX_all +DI", plus_di, n, value_range=(-1, 101), min_valid=10) + check("ADX_all -DI", minus_di, n, value_range=(-1, 101), min_valid=10) + except Exception as e: + errors.append(f"ADX_all: {e}") + tested += 3 + + try: + aroon_up, aroon_down = raptorbt.aroon(high, low, period=14) + check("Aroon_Up", aroon_up, n, value_range=(-1, 101), min_valid=10) + check("Aroon_Down", aroon_down, n, value_range=(-1, 101), min_valid=10) + except Exception as e: + errors.append(f"Aroon: {e}") + tested += 2 + + try: + ppo_line, ppo_signal, ppo_hist = raptorbt.ppo(close, fastperiod=12, slowperiod=26, signalperiod=9) + check("PPO_Line", ppo_line, n, min_valid=10) + check("PPO_Signal", ppo_signal, n, min_valid=10) + check("PPO_Hist", ppo_hist, n, min_valid=10) + except Exception as e: + errors.append(f"PPO: {e}") + tested += 3 + + # ── Volatility 波动率类 ── + print("\n ── Volatility 波动率类 ──") + check("NATR(14)", raptorbt.natr(high, low, close, period=14), n, min_valid=10) + check("TRange", raptorbt.trange(high, low, close), n, min_valid=10) + check("StdDev(20)", raptorbt.stddev(close, period=20, nbdev=1.0), n, min_valid=10) + check("VAR(20)", raptorbt.var(close, period=20, nbdev=1.0), n, min_valid=10) + + # ── Volume 成交量类 ── + print("\n ── Volume 成交量类 ──") + check("AD", raptorbt.ad(high, low, close, volume), n, min_valid=10) + check("ADOSC(3,10)", raptorbt.adosc(high, low, close, volume, fastperiod=3, slowperiod=10), n, min_valid=10) + check("OBV", raptorbt.obv(close, volume), n, min_valid=10) + check("MFI(14)", raptorbt.mfi(high, low, close, volume, period=14), n, value_range=(-1, 101), min_valid=10) + + # ── Price Transform 价格变换类 ── + print("\n ── Price Transform 价格变换类 ──") + check("TypPrice", raptorbt.typprice(high, low, close), n, min_valid=10) + check("MedPrice", raptorbt.medprice(high, low), n, min_valid=10) + check("AvgPrice", raptorbt.avgprice(open_, high, low, close), n, min_valid=10) + check("WclPrice", raptorbt.wclprice(high, low, close), n, min_valid=10) + + # ── Statistic 统计类 ── + print("\n ── Statistic 统计类 ──") + check("LinearReg(20)", raptorbt.linearreg(close, period=20), n, min_valid=10) + check("LinReg_Slope(20)", raptorbt.linearreg_slope(close, period=20), n, min_valid=10) + check("LinReg_Angle(20)", raptorbt.linearreg_angle(close, period=20), n, min_valid=10) + check("LinReg_Intercept(20)", raptorbt.linearreg_intercept(close, period=20), n, min_valid=10) + check("TSF(20)", raptorbt.tsf(close, period=20), n, min_valid=10) + check("Beta(20)", raptorbt.beta(close, close, period=20), n, min_valid=10) + check("Correl(20)", raptorbt.correl(close, close, period=20), n, min_valid=10) + + # ── ADXr ── + print("\n ── ADXr ──") + check("ADXr(14)", raptorbt.adxr(high, low, close, period=14), n, value_range=(-1, 101), min_valid=10) + + # ── APO ── + print("\n ── APO ──") + check("APO(12,26)", raptorbt.apo(close, fastperiod=12, slowperiod=26), n, min_valid=10) + + # ── 汇总 ── + print("\n" + "─" * 60) + if errors: + print(f" ❌ 测试 {tested} 个指标,发现 {len(errors)} 个错误:") + for e in errors: + print(f" - {e}") + else: + print(f" ✅ 全部 {tested} 个指标测试通过,无错误!") + print("─" * 60) + return len(errors) == 0 + + +def run_ferro_strategy_backtest(df): + print("\n" + "=" * 60) + print("8. ferro-ta 指标策略回测 (SAR + ADX + CCI 组合)") + print("=" * 60) + + close = df["close"].values.astype(np.float64) + open_ = df["open"].values.astype(np.float64) + high = df["high"].values.astype(np.float64) + low = df["low"].values.astype(np.float64) + volume = df["tick_volume"].values.astype(np.float64) + timestamps = df["time"].values.astype("int64") + + sar_val = raptorbt.sar(high, low, acceleration=0.02, maximum=0.2) + adx_val, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=14) + cci_val = raptorbt.cci(high, low, close, period=20) + + sar_above = sar_val > close + sar_below = sar_val < close + + adx_strong = adx_val > 25 + bullish_di = plus_di > minus_di + bearish_di = minus_di > plus_di + + cci_oversold = cci_val < -100 + cci_overbought = cci_val > 100 + + entries_long = sar_below & adx_strong & bullish_di & cci_oversold + entries_short = sar_above & adx_strong & bearish_di & cci_overbought + entries = entries_long | entries_short + + exits = (sar_below & bearish_di & adx_strong) | (sar_above & bullish_di & adx_strong) + + warmup = 40 + entries[:warmup] = False + exits[:warmup] = False + + n_long = int(entries_long.sum()) + n_short = int(entries_short.sum()) + n_exits = int(exits.sum()) + print(f" SAR+ADX+CCI 组合信号: {n_long} 次做多, {n_short} 次做空, {n_exits} 次出场") + + config = raptorbt.PyBacktestConfig( + initial_capital=100000.0, + fees=0.001, + slippage=0.0005, + ) + config.set_atr_stop(multiplier=2.5, period=14) + config.set_fixed_target(0.04) + + result = raptorbt.run_single_backtest( + timestamps=timestamps, + open=open_, high=high, low=low, close=close, + volume=volume, + entries=entries, exits=exits, + direction=1, weight=1.0, symbol=SYMBOL, + config=config, + ) + + m = result.metrics + print(f"\n {'─' * 40}") + print(f" SAR+ADX+CCI 策略回测结果") + print(f" {'─' * 40}") + print(f" 总收益率: {m.total_return_pct:>10.2f} %") + print(f" 夏普比率: {m.sharpe_ratio:>10.2f}") + print(f" 最大回撤: {m.max_drawdown_pct:>10.2f} %") + print(f" 总交易数: {m.total_trades:>10d}") + print(f" 胜率: {m.win_rate_pct:>10.1f} %") + print(f" 盈利因子: {m.profit_factor:>10.2f}") + + trades = result.trades() + if trades: + exit_reasons = {} + for t in trades: + r = t.exit_reason + exit_reasons[r] = exit_reasons.get(r, 0) + 1 + print(f" 出场原因分布: {exit_reasons}") + + return result + + +def test_extended_indicators(df): + """P0 批 + Hilbert + 市场状态 + 投资组合工具 全量测试""" + print("\n" + "=" * 60) + print("12. P0 扩展指标全量测试") + print("=" * 60) + + close = df["close"].values.astype(np.float64) + open_ = df["open"].values.astype(np.float64) + high = df["high"].values.astype(np.float64) + low = df["low"].values.astype(np.float64) + volume = df["tick_volume"].values.astype(np.float64) + n = len(close) + errors = [] + + def check(name, arr, expect_len=None, value_range=None, min_valid=1, dtype=None): + if expect_len is not None and len(arr) != expect_len: + errors.append(f"{name}: 长度 {len(arr)} != 期望 {expect_len}") + return + valid = arr[~np.isnan(arr)] + if len(valid) < min_valid: + errors.append(f"{name}: 有效值仅 {len(valid)} 个 (最少需要 {min_valid})") + return + if value_range and len(valid) > 0: + lo, hi = value_range + out = ((valid < lo) | (valid > hi)).sum() + if out > 0: + errors.append(f"{name}: {out} 个值超出 [{lo}, {hi}]") + if dtype is not None and not isinstance(arr, dtype): + errors.append(f"{name}: dtype 应为 {dtype.__name__} 而非 {type(arr).__name__}") + last = arr[-1] + status = f"{last:.4f}" if not np.isnan(last) else "NaN" + print(f" {name:<30} 长度={len(arr):>4} 有效值={len(valid):>4} 最新={status}") + + def check_tuple(name, arrs, expect_len=None, value_ranges=None, min_valids=None): + """测试多返回值的指标""" + if not min_valids: + min_valids = [1] * len(arrs) + if not value_ranges: + value_ranges = [None] * len(arrs) + for i, (a, vmin, vr) in enumerate(zip(arrs, min_valids, value_ranges)): + sub = check(f"{name}[{i}]", a, expect_len, vr, vmin) + + # ── P0 扩展指标 ── + print("\n ── P0 扩展指标 ──") + check("VWMA(20)", raptorbt.vwma(close, volume, period=20), n, min_valid=10) + donch = raptorbt.donchian(high, low, period=20) + check("Donchian(20) upper", donch[0], n, min_valid=10) + check("Donchian(20) middle", donch[1], n, min_valid=10) + check("Donchian(20) lower", donch[2], n, min_valid=10) + # Donchian 的上下边界应当包含收盘价 + donch_upper = donch[0] + donch_lower = donch[2] + valid_idx = ~np.isnan(donch_upper) & ~np.isnan(donch_lower) + if valid_idx.sum() > 0: + bad = ((donch_upper[valid_idx] < close[valid_idx]) | (donch_lower[valid_idx] > close[valid_idx])).sum() + if bad > 0: + errors.append(f"Donchian: {bad} 根 K 线收盘价落在通道之外") + + ci_val = raptorbt.choppiness_index(high, low, close, period=14) + check("Choppiness(14)", ci_val, n, value_range=(-1, 101), min_valid=10) + + check("HullMA(14)", raptorbt.hull_ma(close, period=14), n, min_valid=10) + + cl, cs = raptorbt.chandelier_exit(high, low, close, period=22, multiplier=3.0) + check("Chandelier long_exit", cl, n, min_valid=10) + check("Chandelier short_exit", cs, n, min_valid=10) + # Note: long_exit can be < short_exit in sharp trending markets — this is valid. + # The real check is that both are finite and reasonably close to price. + + tenkan, kijun, senkou_a, senkou_b, chikou = raptorbt.ichimoku( + high, low, close, tenkan_period=9, kijun_period=26, senkou_b_period=52, displacement=26) + check("Ichimoku tenkan", tenkan, n, min_valid=10) + check("Ichimoku kijun", kijun, n, min_valid=10) + check("Ichimoku senkou_a", senkou_a, n, min_valid=10) + check("Ichimoku senkou_b", senkou_b, n, min_valid=10) + check("Ichimoku chikou", chikou, n, min_valid=10) + + pivot, r1, s1, r2, s2 = raptorbt.pivot_points(high, low, close, method="classic") + check("Pivot pivot", pivot, n, min_valid=5) + check("Pivot R1", r1, n, min_valid=5) + check("Pivot S1", s1, n, min_valid=5) + check("Pivot R2", r2, n, min_valid=5) + check("Pivot S2", s2, n, min_valid=5) + # R2 > R1 > pivot > S1 > S2 对于有效值 + valid_pp = ~np.isnan(pivot) & ~np.isnan(r1) & ~np.isnan(s1) & ~np.isnan(r2) & ~np.isnan(s2) + if valid_pp.sum() > 0: + order = (r2[valid_pp] > r1[valid_pp]) & (r1[valid_pp] > pivot[valid_pp]) & \ + (pivot[valid_pp] > s1[valid_pp]) & (s1[valid_pp] > s2[valid_pp]) + bad = (~order).sum() + if bad > 0: + errors.append(f"Pivot: {bad} 处 R2>R1>PIVOT>S1>S2 排序异常") + + # ── Hilbert Transform ── + print("\n ── Hilbert Transform (周期变换) ──") + check("HT_Trendline", raptorbt.ht_trendline(close), n, min_valid=10) + check("HT_DCPeriod", raptorbt.ht_dcperiod(close), n, value_range=(-1, 100), min_valid=10) + check("HT_DCPhase", raptorbt.ht_dcphase(close), n, value_range=(-361, 361), min_valid=10) + ip, quad = raptorbt.ht_phasor(close) + check("HT_Phasor in_phase", ip, n, min_valid=10) + check("HT_Phasor quadrature", quad, n, min_valid=10) + sin, lead = raptorbt.ht_sine(close) + check("HT_Sine", sin, n, value_range=(-1.05, 1.05), min_valid=10) + check("HT_LeadSine", lead, n, value_range=(-1.05, 1.05), min_valid=10) + mode = raptorbt.ht_trendmode(close) + # mode 是 i32 数组 + check("HT_TrendMode", mode, n, value_range=(-2, 2), min_valid=10) + # 验证 TrendMode 只有 0/1 + mode_valid = mode[~np.isnan(mode)] if mode.dtype.kind == 'f' else mode + unique = np.unique(mode_valid) + bad = np.isin(unique, [0, 1]).all() == False and len(unique) > 0 + if len(unique) > 0 and not set(unique) <= {0, 1}: + errors.append(f"HT_TrendMode: 含有非 0/1 值: {unique}") + + # ── 市场状态检测 ── + print("\n ── 市场状态检测 ──") + adx_raw = raptorbt.adx(high, low, close, period=14) + r_adx = raptorbt.regime_adx(adx_raw, threshold=25.0) + check("Regime_ADX", r_adx, n, value_range=(-2, 2), min_valid=5) + # 验证只有 -1/0/1 + unique_r = np.unique(r_adx) + if not set(unique_r) <= {-1, 0, 1}: + errors.append(f"Regime_ADX: 含有非 -1/0/1 值: {unique_r}") + + atr_raw = raptorbt.atr(high, low, close, period=14) + r_combo = raptorbt.regime_combined(adx_raw, atr_raw, close, 25.0, 2.0) + check("Regime_Combined", r_combo, n, value_range=(-2, 2), min_valid=5) + unique_c = np.unique(r_combo) + if not set(unique_c) <= {-1, 0, 1}: + errors.append(f"Regime_Combined: 含有非 -1/0/1 值: {unique_c}") + + breaks = raptorbt.detect_breaks_cusum(close, window=30, threshold=1.5, slack=0.5) + check("Breaks_CUSUM", breaks, n, value_range=(-2, 2), min_valid=5) + unique_b = np.unique(breaks) + if not set(unique_b) <= {-1, 0, 1}: + errors.append(f"Breaks_CUSUM: 含有非 -1/0/1 值: {unique_b}") + + vol_breaks = raptorbt.rolling_variance_break(close, short_window=10, long_window=30, threshold=2.0) + check("Breaks_VarRatio", vol_breaks, n, value_range=(-2, 2), min_valid=5) + unique_v = np.unique(vol_breaks) + if not set(unique_v) <= {-1, 0, 1}: + errors.append(f"Breaks_VarRatio: 含有非 -1/0/1 值: {unique_v}") + + # ── 投资组合工具 ── + print("\n ── 投资组合工具 ──") + # 创建第二组数据(随机偏移模拟) + close2 = close * (1 + np.random.uniform(-0.01, 0.01, n)) + rb = raptorbt.rolling_beta(close, close2, window=20) + check("Rolling_Beta", rb, n, min_valid=10) + + dd_series, max_dd = raptorbt.drawdown_series(close) + check("DrawdownSeries", dd_series, n, value_range=(-1.01, 0.01), min_valid=5) + if max_dd > 0: + errors.append(f"DrawdownSeries max_dd 应为非正值: {max_dd}") + + check("ZScore(20)", raptorbt.zscore_series(close, window=20), n, min_valid=10) + + returns = np.diff(close) / close[:-1] + returns2 = np.diff(close2) / close2[:-1] + rs = raptorbt.relative_strength(returns, returns2) + check("RelativeStrength", rs, None, min_valid=10) + + hedge = 1.0 + spr = raptorbt.spread(close, close2, hedge) + check("Spread", spr, n, min_valid=10) + # 验证 spread ≈ close - close2 + valid_sp = ~np.isnan(spr) & ~np.isnan(close - close2) + if valid_sp.sum() > 0: + diff = np.abs(spr[valid_sp] - (close[valid_sp] - close2[valid_sp])) + bad = (diff > 1e-6).sum() + if bad > 0: + errors.append(f"Spread: {bad} 处与 close - close2 偏差超过 1e-6") + + r = raptorbt.ratio(close, close2) + check("Ratio", r, n, min_valid=10) + # 验证 ratio ≈ close / close2 + valid_r = ~np.isnan(r) & ~np.isnan(close / close2) + if valid_r.sum() > 0: + diff_r = np.abs(r[valid_r] - close[valid_r] / close2[valid_r]) + bad_r = (diff_r > 1e-6).sum() + if bad_r > 0: + errors.append(f"Ratio: {bad_r} 处与 close / close2 偏差超过 1e-6") + + # ── 汇总 ── + print("\n" + "─" * 60) + if errors: + print(f" ❌ 测试发现 {len(errors)} 个错误:") + for e in errors: + print(f" - {e}") + else: + print(f" ✅ 全部扩展指标测试通过,无错误!") + print("─" * 60) + return len(errors) == 0 + + +def main(): + print("╔══════════════════════════════════════════════════════════╗") + print("║ RaptorBT + Mt5Bridge 集成测试 ║") + print("║ 从 MT5 拉取真实数据 → RaptorBT 回测 ║") + print("╚══════════════════════════════════════════════════════════╝\n") + + check_health() + fetch_account() + + df = fetch_klines() + + result1 = run_backtest(df) + result2 = run_multi_indicator_backtest(df) + show_indicators(df) + test_ferro_indicators(df) + test_extended_indicators(df) + result_ferro = run_ferro_strategy_backtest(df) + result3 = run_atr_stop_backtest(df) + run_basket_backtest(df) + run_pairs_backtest(df) + run_monte_carlo(result1, result2) + + export_results(result1, df, strategy_name="sma_cross") + export_results(result2, df, strategy_name="multi_strategy") + if result3: + export_results(result3, df, strategy_name="atr_stop_rr") + if result_ferro: + export_results(result_ferro, df, strategy_name="ferro_sar_adx_cci") + + print("\n" + "=" * 60) + print("✅ 全部测试完成!") + print("=" * 60) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_indicators.rs b/tests/test_indicators.rs new file mode 100644 index 0000000..0c7de8c --- /dev/null +++ b/tests/test_indicators.rs @@ -0,0 +1,484 @@ +//! Integration tests for RaptorBT indicators. + +use raptorbt::indicators::ferro_bridge::{ + chandelier_exit, choppiness_index, detect_breaks_cusum, donchian, drawdown_series, + ht_dcperiod, ht_dcphase, ht_trendline, hull_ma, ichimoku, pivot_points, regime_adx, + relative_strength, rolling_beta, rolling_variance_break, ratio, spread, vwma, zscore_series, +}; +use raptorbt::indicators::momentum::{macd, rsi, stochastic}; +use raptorbt::indicators::strength::adx; +use raptorbt::indicators::trend::{ema, sma, supertrend}; +use raptorbt::indicators::volatility::{atr, bollinger_bands}; +use raptorbt::indicators::volume::vwap; + +fn sample_ohlcv() -> (Vec, Vec, Vec, Vec, Vec) { + // Create sample OHLCV data with 50 bars + let n = 50; + let mut close: Vec = vec![100.0]; + let mut high: Vec = vec![101.0]; + let mut low: Vec = vec![99.0]; + let mut open: Vec = vec![100.0]; + let volume: Vec = vec![1000.0; n]; + + // Generate trending data + for i in 1..n { + let prev_close = close[i - 1]; + let change = ((i as f64 * 0.2).sin() * 2.0) + 0.5; // Slight uptrend with oscillation + let new_close = prev_close + change; + close.push(new_close); + open.push(prev_close); + high.push(new_close.max(prev_close) + 0.5); + low.push(new_close.min(prev_close) - 0.5); + } + + (open, high, low, close, volume) +} + +#[test] +fn test_sma_correctness() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]; + let result = sma(&data, 3).unwrap(); + + // First 2 values should be NaN + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + + // SMA(3) for [1,2,3] = 2.0 + assert!((result[2] - 2.0).abs() < 1e-10); + // SMA(3) for [2,3,4] = 3.0 + assert!((result[3] - 3.0).abs() < 1e-10); + // SMA(3) for [8,9,10] = 9.0 + assert!((result[9] - 9.0).abs() < 1e-10); +} + +#[test] +fn test_ema_correctness() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]; + let result = ema(&data, 3).unwrap(); + + // First 2 values should be NaN + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + + // EMA should be valid from index 2 + assert!(!result[2].is_nan()); + assert!(!result[9].is_nan()); + + // EMA should be between min and max + assert!(result[9] >= 1.0 && result[9] <= 10.0); +} + +#[test] +fn test_rsi_range() { + let (_, _, _, close, _) = sample_ohlcv(); + let result = rsi(&close, 14).unwrap(); + + // Check RSI is in valid range [0, 100] + for (i, &value) in result.iter().enumerate() { + if !value.is_nan() { + assert!( + value >= 0.0 && value <= 100.0, + "RSI at index {} is out of range: {}", + i, + value + ); + } + } +} + +#[test] +fn test_macd_structure() { + let (_, _, _, close, _) = sample_ohlcv(); + let result = macd(&close, 12, 26, 9).unwrap(); + + assert_eq!(result.macd_line.len(), close.len()); + assert_eq!(result.signal_line.len(), close.len()); + assert_eq!(result.histogram.len(), close.len()); + + // MACD line should be valid from index 25 (slow_period - 1) + assert!(result.macd_line[24].is_nan()); + assert!(!result.macd_line[25].is_nan()); +} + +#[test] +fn test_stochastic_range() { + let (_, high, low, close, _) = sample_ohlcv(); + let result = stochastic(&high, &low, &close, 14, 3).unwrap(); + + // %K and %D should be in [0, 100] + for (i, &k) in result.k.iter().enumerate() { + if !k.is_nan() { + assert!(k >= 0.0 && k <= 100.0, "%K at index {} is out of range: {}", i, k); + } + } + + for (i, &d) in result.d.iter().enumerate() { + if !d.is_nan() { + assert!(d >= 0.0 && d <= 100.0, "%D at index {} is out of range: {}", i, d); + } + } +} + +#[test] +fn test_atr_positive() { + let (_, high, low, close, _) = sample_ohlcv(); + let result = atr(&high, &low, &close, 14).unwrap(); + + // ATR should always be non-negative + for (i, &value) in result.iter().enumerate() { + if !value.is_nan() { + assert!(value >= 0.0, "ATR at index {} is negative: {}", i, value); + } + } +} + +#[test] +fn test_bollinger_bands_ordering() { + let (_, _, _, close, _) = sample_ohlcv(); + let result = bollinger_bands(&close, 20, 2.0).unwrap(); + + // Upper > Middle > Lower + for i in 19..close.len() { + if !result.upper[i].is_nan() { + assert!( + result.upper[i] >= result.middle[i], + "Upper band should be >= middle at index {}", + i + ); + assert!( + result.middle[i] >= result.lower[i], + "Middle band should be >= lower at index {}", + i + ); + } + } +} + +#[test] +fn test_adx_range() { + let (_, high, low, close, _) = sample_ohlcv(); + let result = adx(&high, &low, &close, 14).unwrap(); + + // ADX should be in [0, 100] + for (i, &value) in result.iter().enumerate() { + if !value.is_nan() { + assert!( + value >= 0.0 && value <= 100.0, + "ADX at index {} is out of range: {}", + i, + value + ); + } + } +} + +#[test] +fn test_vwap_bounds() { + let (_, high, low, close, volume) = sample_ohlcv(); + let result = vwap(&high, &low, &close, &volume).unwrap(); + + // VWAP should be between the overall min low and max high + let min_low = low.iter().cloned().fold(f64::INFINITY, f64::min); + let max_high = high.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + for (i, &value) in result.iter().enumerate() { + if !value.is_nan() { + assert!( + value >= min_low && value <= max_high, + "VWAP at index {} is out of bounds: {} (should be between {} and {})", + i, + value, + min_low, + max_high + ); + } + } +} + +#[test] +fn test_supertrend_direction() { + let (_, high, low, close, _) = sample_ohlcv(); + let result = supertrend(&high, &low, &close, 10, 3.0).unwrap(); + + // Direction should be either 1 or -1 + for (i, &dir) in result.direction.iter().enumerate() { + if dir != 0 { + assert!( + dir == 1 || dir == -1, + "Supertrend direction at index {} is invalid: {}", + i, + dir + ); + } + } +} + +#[test] +fn test_invalid_period() { + let data = vec![1.0, 2.0, 3.0]; + + // Period of 0 should error + assert!(sma(&data, 0).is_err()); + assert!(ema(&data, 0).is_err()); + assert!(rsi(&data, 0).is_err()); +} + +#[test] +fn test_empty_data() { + let empty: Vec = vec![]; + + let result = sma(&empty, 10).unwrap(); + assert!(result.is_empty()); + + let result = ema(&empty, 10).unwrap(); + assert!(result.is_empty()); +} + +// ========================================================================= +// P0 batch — Extended / Cycle / Regime / Portfolio +// ========================================================================= + +#[test] +fn test_vwma_basic() { + let (open, high, low, close, volume) = sample_ohlcv(); + let _ = open; + let _ = high; + let _ = low; + let r = vwma(&close, &volume, 5).unwrap(); + assert_eq!(r.len(), close.len()); + assert!(r[0].is_nan()); + assert!(r[3].is_nan()); + assert!(!r[4].is_nan()); +} + +#[test] +fn test_vwma_invalid_period() { + let (_, _, _, close, volume) = sample_ohlcv(); + assert!(vwma(&close, &volume, 0).is_err()); +} + +#[test] +fn test_donchian_basic() { + let (_, high, low, _, _) = sample_ohlcv(); + let r = donchian(&high, &low, 10).unwrap(); + assert_eq!(r.upper.len(), high.len()); + assert_eq!(r.middle.len(), high.len()); + assert_eq!(r.lower.len(), high.len()); + for i in 9..high.len() { + assert!(r.upper[i] >= r.middle[i]); + assert!(r.middle[i] >= r.lower[i]); + } +} + +#[test] +fn test_donchian_invalid_period() { + let (_, high, low, _, _) = sample_ohlcv(); + assert!(donchian(&high, &low, 0).is_err()); +} + +#[test] +fn test_choppiness_range() { + let (_, high, low, close, _) = sample_ohlcv(); + let r = choppiness_index(&high, &low, &close, 14).unwrap(); + for (i, &v) in r.iter().enumerate() { + if !v.is_nan() { + assert!(v >= 0.0 && v <= 100.0, "CI at {} out of range: {}", i, v); + } + } +} + +#[test] +fn test_hull_ma_basic() { + let (_, _, _, close, _) = sample_ohlcv(); + let r = hull_ma(&close, 20).unwrap(); + assert_eq!(r.len(), close.len()); + let valid = r.iter().filter(|v| !v.is_nan()).count(); + assert!(valid > 0); +} + +#[test] +fn test_chandelier_exit_ordering() { + let (_, high, low, close, _) = sample_ohlcv(); + let r = chandelier_exit(&high, &low, &close, 10, 2.0).unwrap(); + assert_eq!(r.long_exit.len(), high.len()); + assert_eq!(r.short_exit.len(), high.len()); +} + +#[test] +fn test_chandelier_invalid() { + let (_, high, low, close, _) = sample_ohlcv(); + assert!(chandelier_exit(&high, &low, &close, 0, 2.0).is_err()); + assert!(chandelier_exit(&high, &low, &close, 10, -1.0).is_err()); +} + +#[test] +fn test_ichimoku_structure() { + let (_, high, low, close, _) = sample_ohlcv(); + let r = ichimoku(&high, &low, &close, 9, 26, 52, 26).unwrap(); + assert_eq!(r.tenkan.len(), close.len()); + assert_eq!(r.kijun.len(), close.len()); + assert_eq!(r.senkou_a.len(), close.len()); + assert_eq!(r.senkou_b.len(), close.len()); + assert_eq!(r.chikou.len(), close.len()); + // tenkan period 9 -> first valid at index 8 + assert!(r.tenkan[7].is_nan()); + assert!(!r.tenkan[8].is_nan()); +} + +#[test] +fn test_pivot_points_classic() { + let (_, high, low, close, _) = sample_ohlcv(); + let r = pivot_points(&high, &low, &close, "classic").unwrap(); + assert_eq!(r.pivot.len(), close.len()); + assert!(r.pivot[0].is_nan()); + assert!(!r.pivot[1].is_nan()); +} + +#[test] +fn test_pivot_points_unknown_method() { + let (_, high, low, close, _) = sample_ohlcv(); + assert!(pivot_points(&high, &low, &close, "bogus").is_err()); +} + +#[test] +fn test_ht_trendline_min_length() { + let (_, _, _, close, _) = sample_ohlcv(); + assert!(ht_trendline(&close).is_ok()); + let short = vec![1.0; 10]; + assert!(ht_trendline(&short).is_err()); +} + +#[test] +fn test_ht_dcperiod_dcphase_basic() { + let (_, _, _, close, _) = sample_ohlcv(); + let p = ht_dcperiod(&close).unwrap(); + let ph = ht_dcphase(&close).unwrap(); + assert_eq!(p.len(), close.len()); + assert_eq!(ph.len(), close.len()); +} + +#[test] +fn test_regime_adx_labels() { + // Synthesize ADX series + let adx_input: Vec = (0..50).map(|i| if i < 25 { 10.0 } else { 30.0 }).collect(); + let r = regime_adx(&adx_input, 20.0).unwrap(); + // First 25 bars -> range (0), last 25 -> trend (1) + for i in 0..25 { + assert_eq!(r[i], 0, "idx {} expected 0 got {}", i, r[i]); + } + for i in 25..50 { + assert_eq!(r[i], 1, "idx {} expected 1 got {}", i, r[i]); + } +} + +#[test] +fn test_detect_breaks_cusum_basic() { + let series: Vec = (0..100) + .map(|i| if i < 50 { 0.0 } else { 5.0 }) + .collect(); + let r = detect_breaks_cusum(&series, 10, 5.0, 0.5).unwrap(); + assert_eq!(r.len(), series.len()); + assert_eq!(r[0], 0); +} + +#[test] +fn test_detect_breaks_cusum_invalid_window() { + let v = vec![1.0; 10]; + assert!(detect_breaks_cusum(&v, 1, 1.0, 0.5).is_err()); +} + +#[test] +fn test_rolling_variance_break_basic() { + let series: Vec = (0..60) + .map(|i| if i < 40 { 0.01 } else { 1.0 }) + .collect(); + let r = rolling_variance_break(&series, 5, 20, 2.0).unwrap(); + assert_eq!(r.len(), series.len()); +} + +#[test] +fn test_rolling_variance_break_invalid() { + let v = vec![1.0; 30]; + assert!(rolling_variance_break(&v, 1, 10, 1.0).is_err()); + assert!(rolling_variance_break(&v, 5, 5, 1.0).is_err()); +} + +#[test] +fn test_rolling_beta_basic() { + let (_, _, _, close, _) = sample_ohlcv(); + let bench: Vec = close.iter().map(|x| x * 0.5 + 1.0).collect(); + let r = rolling_beta(&close, &bench, 14).unwrap(); + assert_eq!(r.len(), close.len()); +} + +#[test] +fn test_rolling_beta_invalid_window() { + let v = vec![1.0; 10]; + assert!(rolling_beta(&v, &v, 1).is_err()); +} + +#[test] +fn test_drawdown_series_basic() { + let equity = vec![100.0, 110.0, 105.0, 120.0, 90.0, 95.0, 130.0]; + let r = drawdown_series(&equity).unwrap(); + assert_eq!(r.series.len(), equity.len()); + assert!(r.max_drawdown <= 0.0); + // Max drawdown: 90 / 120 - 1 = -0.25 + assert!((r.max_drawdown - (-0.25)).abs() < 1e-9); + // Per-bar dd is non-positive at all valid points + for &v in &r.series { + assert!(v <= 0.0); + } +} + +#[test] +fn test_zscore_series_basic() { + let x: Vec = (0..30).map(|i| i as f64).collect(); + let r = zscore_series(&x, 10).unwrap(); + assert_eq!(r.len(), x.len()); +} + +#[test] +fn test_zscore_invalid_window() { + let v = vec![1.0; 10]; + assert!(zscore_series(&v, 1).is_err()); +} + +#[test] +fn test_relative_strength_basic() { + let a: Vec = (0..50).map(|i| (i as f64) * 0.01).collect(); + let b: Vec = (0..50).map(|i| (i as f64) * 0.005).collect(); + let r = relative_strength(&a, &b).unwrap(); + assert_eq!(r.len(), a.len()); + // RS should be positive: a - beta*b > 0 since a > b + for i in 10..r.len() { + assert!(r[i] > 0.0); + } +} + +#[test] +fn test_relative_strength_length_mismatch() { + let a = vec![1.0; 10]; + let b = vec![2.0; 5]; + assert!(relative_strength(&a, &b).is_err()); +} + +#[test] +fn test_spread_and_ratio() { + let a = vec![10.0, 20.0, 30.0]; + let b = vec![1.0, 2.0, 3.0]; + let s = spread(&a, &b, 2.0).unwrap(); + let r = ratio(&a, &b).unwrap(); + assert_eq!(s.len(), a.len()); + assert_eq!(r.len(), a.len()); + assert!((s[0] - 8.0).abs() < 1e-9); // 10 - 2*1 + assert!((r[0] - 10.0).abs() < 1e-9); // 10/1 +} + +#[test] +fn test_spread_length_mismatch() { + let a = vec![1.0; 5]; + let b = vec![2.0; 3]; + assert!(spread(&a, &b, 1.0).is_err()); + assert!(ratio(&a, &b).is_err()); +} diff --git a/tests/test_portfolio.rs b/tests/test_portfolio.rs new file mode 100644 index 0000000..35dcaf7 --- /dev/null +++ b/tests/test_portfolio.rs @@ -0,0 +1,309 @@ +//! Integration tests for RaptorBT portfolio engine. + +use raptorbt::core::types::{ + BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig, +}; +use raptorbt::portfolio::engine::PortfolioEngine; + +fn sample_ohlcv() -> OhlcvData { + // Create trending sample data + let n = 100; + let mut close = vec![100.0]; + let mut open = vec![100.0]; + let mut high = vec![101.0]; + let mut low = vec![99.0]; + + for i in 1..n { + let trend = (i as f64) * 0.5; // Upward trend + let noise = ((i as f64) * 0.3).sin() * 2.0; + let new_close = 100.0 + trend + noise; + close.push(new_close); + open.push(close[i - 1]); + high.push(new_close + 1.0); + low.push(new_close - 1.0); + } + + OhlcvData { + timestamps: (0..n as i64).collect(), + open, + high, + low, + close, + volume: vec![1000.0; n], + } +} + +fn simple_signals(n: usize) -> CompiledSignals { + // Entry at bar 10, exit at bar 50 + let mut entries = vec![false; n]; + let mut exits = vec![false; n]; + entries[10] = true; + exits[50] = true; + + CompiledSignals { + symbol: "TEST".to_string(), + entries, + exits, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + } +} + +#[test] +fn test_basic_backtest() { + let ohlcv = sample_ohlcv(); + let signals = simple_signals(ohlcv.len()); + + let config = BacktestConfig::default(); + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // Should have 1 complete trade + assert_eq!(result.trades.len(), 1); + + // Equity curve should have same length as data + assert_eq!(result.equity_curve.len(), ohlcv.len()); + + // In an uptrend, should have positive return + assert!(result.metrics.total_return_pct > 0.0); +} + +#[test] +fn test_multiple_trades() { + let ohlcv = sample_ohlcv(); + let n = ohlcv.len(); + + // Multiple trades + let mut entries = vec![false; n]; + let mut exits = vec![false; n]; + entries[10] = true; + exits[20] = true; + entries[30] = true; + exits[40] = true; + entries[50] = true; + exits[60] = true; + + let signals = CompiledSignals { + symbol: "TEST".to_string(), + entries, + exits, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + let config = BacktestConfig::default(); + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // Should have 3 trades + assert_eq!(result.trades.len(), 3); +} + +#[test] +fn test_with_fees() { + let ohlcv = sample_ohlcv(); + let signals = simple_signals(ohlcv.len()); + + let config = BacktestConfig { + fees: 0.01, // 1% fee + ..Default::default() + }; + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // Trade should have fees deducted + assert!(result.trades[0].fees > 0.0); + + // Return should be lower due to fees + let config_no_fees = BacktestConfig::default(); + let engine_no_fees = PortfolioEngine::new(config_no_fees); + let result_no_fees = engine_no_fees.run_single(&ohlcv, &signals); + + assert!(result.metrics.end_value < result_no_fees.metrics.end_value); +} + +#[test] +fn test_fixed_stop_loss() { + let ohlcv = sample_ohlcv(); + let n = ohlcv.len(); + + // Entry at bar 10 + let mut entries = vec![false; n]; + entries[10] = true; + let exits = vec![false; n]; // No exit signal + + let signals = CompiledSignals { + symbol: "TEST".to_string(), + entries, + exits, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + let config = BacktestConfig { + stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop + ..Default::default() + }; + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // Should have at least one trade (may exit on stop or end of data) + assert!(!result.trades.is_empty()); +} + +#[test] +fn test_fixed_take_profit() { + let ohlcv = sample_ohlcv(); + let n = ohlcv.len(); + + // Entry at bar 10 + let mut entries = vec![false; n]; + entries[10] = true; + let exits = vec![false; n]; // No exit signal + + let signals = CompiledSignals { + symbol: "TEST".to_string(), + entries, + exits, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + let config = BacktestConfig { + target: TargetConfig::Fixed { percent: 0.10 }, // 10% target + ..Default::default() + }; + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // Should have at least one trade + assert!(!result.trades.is_empty()); +} + +#[test] +fn test_no_trades() { + let ohlcv = sample_ohlcv(); + let n = ohlcv.len(); + + // No entry signals + let signals = CompiledSignals { + symbol: "TEST".to_string(), + entries: vec![false; n], + exits: vec![false; n], + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + let config = BacktestConfig::default(); + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // Should have no trades + assert_eq!(result.trades.len(), 0); + assert_eq!(result.metrics.total_trades, 0); + + // Equity should remain at initial capital + assert!((result.metrics.end_value - result.metrics.start_value).abs() < 1e-10); +} + +#[test] +fn test_drawdown_positive() { + let ohlcv = sample_ohlcv(); + let signals = simple_signals(ohlcv.len()); + + let config = BacktestConfig::default(); + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // All drawdown values should be non-negative + for dd in &result.drawdown_curve { + assert!(*dd >= 0.0, "Drawdown should be non-negative"); + } +} + +#[test] +fn test_short_direction() { + // Create downtrend data + let n = 100; + let mut close = vec![100.0]; + for i in 1..n { + close.push(100.0 - (i as f64) * 0.3); // Downward trend + } + + let ohlcv = OhlcvData { + timestamps: (0..n as i64).collect(), + open: close.iter().skip(1).chain(std::iter::once(&close[n - 1])).cloned().collect(), + high: close.iter().map(|c| c + 1.0).collect(), + low: close.iter().map(|c| c - 1.0).collect(), + close: close.clone(), + volume: vec![1000.0; n], + }; + + // Entry at bar 10, exit at bar 50 + let mut entries = vec![false; n]; + let mut exits = vec![false; n]; + entries[10] = true; + exits[50] = true; + + let signals = CompiledSignals { + symbol: "TEST".to_string(), + entries, + exits, + position_sizes: None, + direction: Direction::Short, // Short direction + weight: 1.0, + }; + + let config = BacktestConfig::default(); + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // Short in a downtrend should be profitable + assert!(result.trades[0].pnl > 0.0); +} + +#[test] +fn test_metrics_consistency() { + let ohlcv = sample_ohlcv(); + let n = ohlcv.len(); + + // Multiple trades for statistics + let mut entries = vec![false; n]; + let mut exits = vec![false; n]; + for i in (10..90).step_by(20) { + entries[i] = true; + exits[i + 10] = true; + } + + let signals = CompiledSignals { + symbol: "TEST".to_string(), + entries, + exits, + position_sizes: None, + direction: Direction::Long, + weight: 1.0, + }; + + let config = BacktestConfig::default(); + let engine = PortfolioEngine::new(config); + let result = engine.run_single(&ohlcv, &signals); + + // Total trades should equal winning + losing + assert_eq!( + result.metrics.total_trades, + result.metrics.winning_trades + result.metrics.losing_trades + ); + + // Win rate should be in [0, 100] + assert!(result.metrics.win_rate_pct >= 0.0); + assert!(result.metrics.win_rate_pct <= 100.0); + + // Exposure should be in [0, 100] + assert!(result.metrics.exposure_pct >= 0.0); + assert!(result.metrics.exposure_pct <= 100.0); +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c1170e5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "raptorbt" +version = "0.2.0" +source = { editable = "." } diff --git a/指标扩展.md b/指标扩展.md new file mode 100644 index 0000000..aa4a321 --- /dev/null +++ b/指标扩展.md @@ -0,0 +1,246 @@ +当然可以,RaptorBT 是开源的 Rust 项目,结构很清晰。让我先看看现有指标的实现模式: +The user is asking about modifying the RaptorBT source code to add custom indicators. Let me look at the existing indicator implementation to understand the pattern, then explain how to add a new indicator. + +现在看看 `lib.rs` 里的模块注册: + +好,现在我对整个代码结构非常清楚了。下面给你完整的说明: + +--- + +## 添加自定义指标的完整流程 + +RaptorBT 的指标架构非常清晰,添加一个新指标需要改 **3 个文件**,我以添加 **CCI(Commodity Channel Index)** 为例演示: + +### 第 1 步:在 Rust 里实现指标逻辑 + +在 [momentum.rs](file:///c:/Users/Administrator/Desktop/raptorbt/src/indicators/momentum.rs) 中添加函数(或新建文件都行,这里选 momentum.rs 因为 CCI 属于动量类): + +```rust +/// Commodity Channel Index (CCI). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `period` - Lookback period (default: 20) +/// +/// # Returns +/// Vector of CCI values (NaN for warmup period) +pub fn cci(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("CCI period must be > 0")); + } + let n = close.len(); + if n != high.len() || n != low.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + + let mut result = vec![f64::NAN; n]; + if period >= n { + return Ok(result); + } + + // Typical Price + let tp: Vec = (0..n).map(|i| (high[i] + low[i] + close[i]) / 3.0).collect(); + + // SMA of Typical Price + Mean Deviation + for i in (period - 1)..n { + let start = i + 1 - period; + let tp_sma: f64 = tp[start..=i].iter().sum::() / period as f64; + let mean_dev: f64 = tp[start..=i].iter().map(|x| (x - tp_sma).abs()).sum::() / period as f64; + + if mean_dev > f64::EPSILON { + result[i] = (tp[i] - tp_sma) / (0.015 * mean_dev); + } else { + result[i] = 0.0; + } + } + + Ok(result) +} +``` + +### 第 2 步:在 mod.rs 导出 + +在 [mod.rs](file:///c:/Users/Administrator/Desktop/raptorbt/src/indicators/mod.rs) 的 `pub use momentum::...` 行加上 `cci`: + +```rust +pub use momentum::{cci, macd, rsi, stochastic, MacdResult, StochasticResult}; +``` + +### 第 3 步:在 bindings.rs 添加 Python 绑定 + +在 [bindings.rs](file:///c:/Users/Administrator/Desktop/raptorbt/src/python/bindings.rs) 的指标函数区域添加: + +```rust +/// Commodity Channel Index. +#[pyfunction] +pub fn cci<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::momentum::cci(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} +``` + +### 第 4 步:在 lib.rs 注册 + +在 [lib.rs](file:///c:/Users/Administrator/Desktop/raptorbt/src/lib.rs) 的指标注册区添加: + +```rust +m.add_function(wrap_pyfunction!(python::bindings::cci, m)?)?; +``` + +### 第 5 步:编译 & 使用 + +```powershell +maturin develop --release +``` + +然后 Python 里就能用了: + +```python +cci = raptorbt.cci(high, low, close, period=20) +``` + +--- + +## 总结:3 文件修改清单 + +| 文件 | 改什么 | 位置 | +| ---------------------------------------- | ------------------------------------------------ | ---------------- | +| `src/indicators/momentum.rs`(或新文件) | 实现 `pub fn cci(...) -> Result>` | 和 rsi/macd 同级 | +| `src/indicators/mod.rs` | `pub use momentum::cci` | 导出行 | +| `src/python/bindings.rs` | 添加 `#[pyfunction] pub fn cci<...>` | 指标函数区 | +| `src/lib.rs` | `m.add_function(wrap_pyfunction!(...::cci, m)?)` | 指标注册区 | + +**规律**:所有现有指标(sma/ema/rsi/macd/atr/bollinger_bands/adx/vwap/supertrend/stochastic/rolling_min/rolling_max)都是这个模式。你照着抄一个最简单的(比如 `sma`),改成自己的计算逻辑就行。 + + + +--- + +## 添加自定义指标的完整流程 + +RaptorBT 的指标架构非常清晰,添加一个新指标需要改 **3 个文件**,我以添加 **CCI(Commodity Channel Index)** 为例演示: + +### 第 1 步:在 Rust 里实现指标逻辑 + +在 [momentum.rs](file:///c:/Users/Administrator/Desktop/raptorbt/src/indicators/momentum.rs) 中添加函数(或新建文件都行,这里选 momentum.rs 因为 CCI 属于动量类): + +```rust +/// Commodity Channel Index (CCI). +/// +/// # Arguments +/// * `high` - High prices +/// * `low` - Low prices +/// * `close` - Close prices +/// * `period` - Lookback period (default: 20) +/// +/// # Returns +/// Vector of CCI values (NaN for warmup period) +pub fn cci(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("CCI period must be > 0")); + } + let n = close.len(); + if n != high.len() || n != low.len() { + return Err(RaptorError::length_mismatch(n, high.len())); + } + + let mut result = vec![f64::NAN; n]; + if period >= n { + return Ok(result); + } + + // Typical Price + let tp: Vec = (0..n).map(|i| (high[i] + low[i] + close[i]) / 3.0).collect(); + + // SMA of Typical Price + Mean Deviation + for i in (period - 1)..n { + let start = i + 1 - period; + let tp_sma: f64 = tp[start..=i].iter().sum::() / period as f64; + let mean_dev: f64 = tp[start..=i].iter().map(|x| (x - tp_sma).abs()).sum::() / period as f64; + + if mean_dev > f64::EPSILON { + result[i] = (tp[i] - tp_sma) / (0.015 * mean_dev); + } else { + result[i] = 0.0; + } + } + + Ok(result) +} +``` + +### 第 2 步:在 mod.rs 导出 + +在 [mod.rs](file:///c:/Users/Administrator/Desktop/raptorbt/src/indicators/mod.rs) 的 `pub use momentum::...` 行加上 `cci`: + +```rust +pub use momentum::{cci, macd, rsi, stochastic, MacdResult, StochasticResult}; +``` + +### 第 3 步:在 bindings.rs 添加 Python 绑定 + +在 [bindings.rs](file:///c:/Users/Administrator/Desktop/raptorbt/src/python/bindings.rs) 的指标函数区域添加: + +```rust +/// Commodity Channel Index. +#[pyfunction] +pub fn cci<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::momentum::cci(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} +``` + +### 第 4 步:在 lib.rs 注册 + +在 [lib.rs](file:///c:/Users/Administrator/Desktop/raptorbt/src/lib.rs) 的指标注册区添加: + +```rust +m.add_function(wrap_pyfunction!(python::bindings::cci, m)?)?; +``` + +### 第 5 步:编译 & 使用 + +```powershell +maturin develop --release +``` + +然后 Python 里就能用了: + +```python +cci = raptorbt.cci(high, low, close, period=20) +``` + +--- + +## 总结:3 文件修改清单 + +| 文件 | 改什么 | 位置 | +| ---------------------------------------- | ------------------------------------------------ | ---------------- | +| `src/indicators/momentum.rs`(或新文件) | 实现 `pub fn cci(...) -> Result>` | 和 rsi/macd 同级 | +| `src/indicators/mod.rs` | `pub use momentum::cci` | 导出行 | +| `src/python/bindings.rs` | 添加 `#[pyfunction] pub fn cci<...>` | 指标函数区 | +| `src/lib.rs` | `m.add_function(wrap_pyfunction!(...::cci, m)?)` | 指标注册区 | + +**规律**:所有现有指标(sma/ema/rsi/macd/atr/bollinger_bands/adx/vwap/supertrend/stochastic/rolling_min/rolling_max)都是这个模式。你照着抄一个最简单的(比如 `sma`),改成自己的计算逻辑就行。 \ No newline at end of file