Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc584dbff9 | |||
| 58516672d7 | |||
| 7b560463f6 | |||
| 6cd1714a9f | |||
| 6e45da2636 | |||
| cf5d7764ba | |||
| e370120f4e | |||
| f9575df3f6 | |||
| 139f7f26e0 |
@@ -7,6 +7,12 @@ on:
|
||||
branches: ["main"]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: "Simulate a release publish (runs build + publish jobs)"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -133,7 +139,7 @@ jobs:
|
||||
build-wheels-linux:
|
||||
name: Build wheels (linux / py${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -158,7 +164,7 @@ jobs:
|
||||
build-wheels-macos:
|
||||
name: Build wheels (macos / py${{ matrix.python-version }})
|
||||
runs-on: macos-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -188,7 +194,7 @@ jobs:
|
||||
build-wheels-windows:
|
||||
name: Build wheels (windows / py${{ matrix.python-version }})
|
||||
runs-on: windows-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -217,7 +223,7 @@ jobs:
|
||||
build-sdist:
|
||||
name: Build source distribution
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -244,7 +250,7 @@ jobs:
|
||||
- build-wheels-macos
|
||||
- build-wheels-windows
|
||||
- build-sdist
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/ferro-ta
|
||||
@@ -313,7 +319,7 @@ jobs:
|
||||
publish-cratesio:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -333,8 +339,10 @@ jobs:
|
||||
sbom:
|
||||
name: Generate SBOM (Python + Rust)
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
needs:
|
||||
- publish
|
||||
- publish-cratesio
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
@@ -370,7 +378,7 @@ jobs:
|
||||
run: cargo install cargo-cyclonedx --locked
|
||||
|
||||
- name: Generate Rust SBOM (CycloneDX)
|
||||
run: cargo cyclonedx --format json --output-cdx ferro-ta-rust-sbom.cdx.json
|
||||
run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx
|
||||
|
||||
- name: Upload Python SBOM to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
working-directory: wasm
|
||||
run: wasm-pack test --node
|
||||
|
||||
- name: Build WASM package (nodejs target)
|
||||
- name: Build WASM package (both targets)
|
||||
working-directory: wasm
|
||||
run: wasm-pack build --target nodejs --out-dir pkg
|
||||
run: npm run build
|
||||
|
||||
- name: Check API manifest is current
|
||||
run: python3 scripts/check_api_manifest.py
|
||||
@@ -42,11 +42,17 @@ jobs:
|
||||
working-directory: wasm
|
||||
run: node bench.js --json ../wasm_benchmark.json
|
||||
|
||||
- name: Upload WASM package artifact
|
||||
- name: Upload WASM node package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-pkg
|
||||
path: wasm/pkg/
|
||||
name: wasm-pkg-node
|
||||
path: wasm/node/
|
||||
|
||||
- name: Upload WASM web package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-pkg-web
|
||||
path: wasm/web/
|
||||
|
||||
- name: Upload WASM benchmark artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -3,6 +3,12 @@ name: Publish WASM to npm
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: "Publish WASM package to npm"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,13 +18,16 @@ jobs:
|
||||
publish:
|
||||
name: Build and publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'release' && github.event.action == 'published')
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install Rust and wasm-pack
|
||||
@@ -27,6 +36,10 @@ jobs:
|
||||
targets: wasm32-unknown-unknown
|
||||
- run: cargo install wasm-pack
|
||||
|
||||
- name: Run WASM tests
|
||||
working-directory: wasm
|
||||
run: wasm-pack test --node
|
||||
|
||||
- name: Build WASM package
|
||||
run: |
|
||||
cd wasm
|
||||
|
||||
@@ -37,6 +37,8 @@ env/
|
||||
# WASM build output
|
||||
wasm/pkg/
|
||||
wasm/pkg-web/
|
||||
wasm/node/
|
||||
wasm/web/
|
||||
benchmark_vs_talib.json
|
||||
wasm_benchmark.json
|
||||
.wasm_benchmark.prepush.json
|
||||
|
||||
@@ -9,6 +9,37 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.2] — 2026-04-01
|
||||
|
||||
### Changed
|
||||
|
||||
- WASM npm package now ships both Node.js (CommonJS) and browser/web worker
|
||||
(ESM) builds via conditional exports in package.json.
|
||||
- Fixed wasm-publish.yml: added job condition, workflow_dispatch inputs, and
|
||||
pre-publish test gate.
|
||||
- Fixed CI.yml: SBOM job now waits for both PyPI and crates.io publish.
|
||||
- Aligned Node.js version to 20 across all CI workflows.
|
||||
- Rewrote wasm/README.md and ferro_ta_core/README.md to reflect full feature
|
||||
parity (200+ WASM exports, 22 core modules).
|
||||
|
||||
## [1.1.1] — 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- Full feature parity across Rust core, Python, and WASM targets.
|
||||
- 56 new pure-Rust indicator functions in ferro_ta_core: ROC/ROCP/ROCR/ROCR100,
|
||||
WILLR, AROON/AROONOSC, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC,
|
||||
DEMA, TEMA, TRIMA, KAMA, T3, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE,
|
||||
MACDFIX, MACDEXT, MA (generic dispatcher), MAVP, VAR, LINEARREG variants,
|
||||
TSF, BETA, CORREL, NATR, and 19 math operators/transforms.
|
||||
- 120+ new WASM bindings: all 61 candlestick patterns (via macro), 9 streaming
|
||||
API structs, options pricing/greeks/IV/chain/surface, futures basis/roll/curve/
|
||||
synthetic, backtest engine (close-only + OHLCV), walk-forward analysis,
|
||||
Monte Carlo bootstrap, performance metrics, batch operations, portfolio
|
||||
analytics, and signal utilities.
|
||||
- `workflow_dispatch` trigger added to `wasm-publish.yml` for manual npm
|
||||
publishing.
|
||||
|
||||
## [1.0.6] — 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+2
-2
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ferro_ta_core",
|
||||
@@ -222,7 +222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"serde",
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
edition = "2021"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
license = "MIT"
|
||||
@@ -30,7 +30,7 @@ ndarray = "0.16"
|
||||
rayon = "1.10"
|
||||
log = "0.4"
|
||||
pyo3-log = "0.12"
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.0", features = ["serde"] }
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.2", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
+1
-1
@@ -71,6 +71,6 @@ pip install ferro-ta --no-binary ferro-ta
|
||||
|
||||
## Known limitations
|
||||
|
||||
- WASM binding: only 6 indicators exposed (see `wasm/README.md`).
|
||||
- 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.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{% set name = "ferro-ta" %}
|
||||
{% set version = "1.1.0" %}
|
||||
{% set version = "1.1.2" %}
|
||||
|
||||
package:
|
||||
name: {{ name|lower }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
edition = "2021"
|
||||
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
|
||||
license = "MIT"
|
||||
|
||||
@@ -7,13 +7,13 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
|
||||
|
||||
- Rust-native technical analysis workloads
|
||||
- custom services and backtesting engines
|
||||
- future non-Python bindings such as WASM and other FFI layers
|
||||
- non-Python bindings (WASM, FFI)
|
||||
|
||||
## Installation
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ferro_ta_core = "1.1.0"
|
||||
ferro_ta_core = "1.1.2"
|
||||
```
|
||||
|
||||
## Design
|
||||
@@ -25,12 +25,31 @@ ferro_ta_core = "1.1.0"
|
||||
|
||||
## Modules
|
||||
|
||||
- `overlap` - moving averages, MACD, Bollinger Bands
|
||||
- `momentum` - RSI, MOM
|
||||
- `volatility` - ATR, TRANGE
|
||||
- `volume` - OBV
|
||||
- `statistic` - STDDEV
|
||||
- `math` - rolling SUM/MAX/MIN helpers
|
||||
| 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
|
||||
|
||||
@@ -49,18 +68,7 @@ fn main() {
|
||||
|
||||
## Relationship To `ferro-ta`
|
||||
|
||||
The published Python package:
|
||||
|
||||
- crate: `ferro_ta`
|
||||
- PyPI package: `ferro-ta`
|
||||
|
||||
wraps this crate with PyO3 bindings and adds:
|
||||
|
||||
- NumPy conversion
|
||||
- pandas/polars wrappers
|
||||
- streaming classes
|
||||
- batch helpers
|
||||
- higher-level Python tooling
|
||||
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.
|
||||
|
||||
|
||||
@@ -113,6 +113,61 @@ pub fn sliding_min(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Element-wise arithmetic operators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Element-wise addition of two arrays.
|
||||
pub fn add(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
a.iter().zip(b.iter()).map(|(&x, &y)| x + y).collect()
|
||||
}
|
||||
|
||||
/// Element-wise subtraction of two arrays.
|
||||
pub fn sub(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
a.iter().zip(b.iter()).map(|(&x, &y)| x - y).collect()
|
||||
}
|
||||
|
||||
/// Element-wise multiplication of two arrays.
|
||||
pub fn mult(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
a.iter().zip(b.iter()).map(|(&x, &y)| x * y).collect()
|
||||
}
|
||||
|
||||
/// Element-wise division of two arrays (NaN where b=0).
|
||||
pub fn div(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(&x, &y)| if y != 0.0 { x / y } else { f64::NAN })
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Element-wise math transforms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! unary_transform {
|
||||
($name:ident, $method:ident) => {
|
||||
pub fn $name(real: &[f64]) -> Vec<f64> {
|
||||
real.iter().map(|&x| x.$method()).collect()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
unary_transform!(math_acos, acos);
|
||||
unary_transform!(math_asin, asin);
|
||||
unary_transform!(math_atan, atan);
|
||||
unary_transform!(math_ceil, ceil);
|
||||
unary_transform!(math_cos, cos);
|
||||
unary_transform!(math_cosh, cosh);
|
||||
unary_transform!(math_exp, exp);
|
||||
unary_transform!(math_floor, floor);
|
||||
unary_transform!(math_ln, ln);
|
||||
unary_transform!(math_log10, log10);
|
||||
unary_transform!(math_sin, sin);
|
||||
unary_transform!(math_sinh, sinh);
|
||||
unary_transform!(math_sqrt, sqrt);
|
||||
unary_transform!(math_tan, tan);
|
||||
unary_transform!(math_tanh, tanh);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -446,6 +446,435 @@ pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate of Change variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rate of Change: `(close[i] - close[i-p]) / close[i-p] * 100`.
|
||||
pub fn roc(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
let prev = close[i - timeperiod];
|
||||
if prev != 0.0 {
|
||||
result[i] = (close[i] - prev) / prev * 100.0;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Rate of Change Percentage: `(close[i] - close[i-p]) / close[i-p]`.
|
||||
pub fn rocp(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
let prev = close[i - timeperiod];
|
||||
if prev != 0.0 {
|
||||
result[i] = (close[i] - prev) / prev;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Rate of Change Ratio: `close[i] / close[i-p]`.
|
||||
pub fn rocr(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
let prev = close[i - timeperiod];
|
||||
if prev != 0.0 {
|
||||
result[i] = close[i] / prev;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Rate of Change Ratio x 100: `close[i] / close[i-p] * 100`.
|
||||
pub fn rocr100(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
let prev = close[i - timeperiod];
|
||||
if prev != 0.0 {
|
||||
result[i] = close[i] / prev * 100.0;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Williams %R
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Williams %R: `-100 * (HH - close) / (HH - LL)` over the window.
|
||||
/// Returns values in `[-100, 0]`.
|
||||
pub fn willr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let start = i + 1 - timeperiod;
|
||||
let mut highest = f64::NEG_INFINITY;
|
||||
let mut lowest = f64::INFINITY;
|
||||
for j in start..=i {
|
||||
if high[j] > highest {
|
||||
highest = high[j];
|
||||
}
|
||||
if low[j] < lowest {
|
||||
lowest = low[j];
|
||||
}
|
||||
}
|
||||
let range = highest - lowest;
|
||||
result[i] = if range != 0.0 {
|
||||
-100.0 * (highest - close[i]) / range
|
||||
} else {
|
||||
-50.0
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aroon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aroon indicator. Returns `(aroon_down, aroon_up)`.
|
||||
pub fn aroon(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
let mut aroon_down = vec![f64::NAN; n];
|
||||
let mut aroon_up = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n <= timeperiod {
|
||||
return (aroon_down, aroon_up);
|
||||
}
|
||||
let period_f = timeperiod as f64;
|
||||
let window_size = timeperiod + 1;
|
||||
for i in timeperiod..n {
|
||||
let start = i + 1 - window_size;
|
||||
let mut max_val = high[start];
|
||||
let mut min_val = low[start];
|
||||
let mut max_idx = 0usize;
|
||||
let mut min_idx = 0usize;
|
||||
for j in 0..window_size {
|
||||
if high[start + j] >= max_val {
|
||||
max_val = high[start + j];
|
||||
max_idx = j;
|
||||
}
|
||||
if low[start + j] <= min_val {
|
||||
min_val = low[start + j];
|
||||
min_idx = j;
|
||||
}
|
||||
}
|
||||
aroon_up[i] = 100.0 * (max_idx as f64) / period_f;
|
||||
aroon_down[i] = 100.0 * (min_idx as f64) / period_f;
|
||||
}
|
||||
(aroon_down, aroon_up)
|
||||
}
|
||||
|
||||
/// Aroon Oscillator: `aroon_up - aroon_down`.
|
||||
pub fn aroonosc(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (down, up) = aroon(high, low, timeperiod);
|
||||
up.iter()
|
||||
.zip(down.iter())
|
||||
.map(|(&u, &d)| {
|
||||
if u.is_nan() || d.is_nan() {
|
||||
f64::NAN
|
||||
} else {
|
||||
u - d
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CCI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Commodity Channel Index: `(tp - SMA(tp)) / (0.015 * MAD)`.
|
||||
pub fn cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let tp: Vec<f64> = high
|
||||
.iter()
|
||||
.zip(low.iter())
|
||||
.zip(close.iter())
|
||||
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
|
||||
.collect();
|
||||
for i in (timeperiod - 1)..n {
|
||||
let window = &tp[(i + 1 - timeperiod)..=i];
|
||||
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
|
||||
let mad: f64 = window.iter().map(|&x| (x - mean).abs()).sum::<f64>() / timeperiod as f64;
|
||||
result[i] = if mad != 0.0 {
|
||||
(tp[i] - mean) / (0.015 * mad)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BOP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Balance of Power: `(close - open) / (high - low)`.
|
||||
pub fn bop(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
open.iter()
|
||||
.zip(high.iter())
|
||||
.zip(low.iter())
|
||||
.zip(close.iter())
|
||||
.map(|(((&o, &h), &l), &c)| {
|
||||
let range = h - l;
|
||||
if range != 0.0 {
|
||||
(c - o) / range
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stochastic RSI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stochastic RSI. Returns `(fastk, fastd)`.
|
||||
pub fn stochrsi(
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
fastk_period: usize,
|
||||
fastd_period: usize,
|
||||
) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]);
|
||||
if timeperiod == 0 || fastk_period == 0 || fastd_period == 0 {
|
||||
return nan_pair();
|
||||
}
|
||||
|
||||
let rsi_vals = rsi(close, timeperiod);
|
||||
let rsi_warmup = timeperiod;
|
||||
let k_warmup = rsi_warmup + fastk_period - 1;
|
||||
let d_warmup = k_warmup + fastd_period - 1;
|
||||
|
||||
let mut fastk = vec![f64::NAN; n];
|
||||
let mut fastd = vec![f64::NAN; n];
|
||||
|
||||
for i in k_warmup..n {
|
||||
if rsi_vals[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let start = i + 1 - fastk_period;
|
||||
if (start..=i).any(|j| rsi_vals[j].is_nan()) {
|
||||
continue;
|
||||
}
|
||||
let mx = rsi_vals[start..=i]
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let mn = rsi_vals[start..=i]
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
fastk[i] = if mx != mn {
|
||||
100.0 * (rsi_vals[i] - mn) / (mx - mn)
|
||||
} else {
|
||||
50.0
|
||||
};
|
||||
}
|
||||
|
||||
for i in d_warmup..n {
|
||||
let start = i + 1 - fastd_period;
|
||||
let window = &fastk[start..=i];
|
||||
if window.iter().all(|v| !v.is_nan()) {
|
||||
fastd[i] = window.iter().sum::<f64>() / fastd_period as f64;
|
||||
}
|
||||
}
|
||||
(fastk, fastd)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// APO / PPO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Absolute Price Oscillator: `fast EMA - slow EMA`.
|
||||
pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if fastperiod == 0 || slowperiod == 0 || fastperiod >= slowperiod {
|
||||
return result;
|
||||
}
|
||||
let fast = crate::overlap::ema(close, fastperiod);
|
||||
let slow = crate::overlap::ema(close, slowperiod);
|
||||
let warmup = slowperiod - 1;
|
||||
for i in warmup..n {
|
||||
if !fast[i].is_nan() && !slow[i].is_nan() {
|
||||
result[i] = fast[i] - slow[i];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Percentage Price Oscillator: `(fast EMA - slow EMA) / slow EMA * 100`.
|
||||
/// Returns `(ppo_line, signal_line, histogram)`.
|
||||
pub fn ppo(
|
||||
close: &[f64],
|
||||
fastperiod: usize,
|
||||
slowperiod: usize,
|
||||
signalperiod: usize,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]);
|
||||
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod {
|
||||
return nan3();
|
||||
}
|
||||
let fast = crate::overlap::ema(close, fastperiod);
|
||||
let slow = crate::overlap::ema(close, slowperiod);
|
||||
let warmup = slowperiod - 1;
|
||||
|
||||
let mut ppo_line = vec![f64::NAN; n];
|
||||
for i in warmup..n {
|
||||
if !fast[i].is_nan() && !slow[i].is_nan() && slow[i] != 0.0 {
|
||||
ppo_line[i] = (fast[i] - slow[i]) / slow[i] * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Signal line = EMA of PPO line (only over valid values)
|
||||
let signal = crate::overlap::ema(&ppo_line, signalperiod);
|
||||
let mut signal_line = vec![f64::NAN; n];
|
||||
let mut hist = vec![f64::NAN; n];
|
||||
let sig_warmup = warmup + signalperiod - 1;
|
||||
for i in sig_warmup..n {
|
||||
if !ppo_line[i].is_nan() && !signal[i].is_nan() {
|
||||
signal_line[i] = signal[i];
|
||||
hist[i] = ppo_line[i] - signal[i];
|
||||
}
|
||||
}
|
||||
(ppo_line, signal_line, hist)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CMO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Chande Momentum Oscillator: `100 * (sum_gains - sum_losses) / (sum_gains + sum_losses)`.
|
||||
pub fn cmo(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod + 1 {
|
||||
return result;
|
||||
}
|
||||
let changes: Vec<f64> = close.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
for i in timeperiod..n {
|
||||
let mut ups = 0.0_f64;
|
||||
let mut downs = 0.0_f64;
|
||||
for ch in &changes[(i - timeperiod)..i] {
|
||||
if *ch > 0.0 {
|
||||
ups += ch;
|
||||
} else {
|
||||
downs -= ch;
|
||||
}
|
||||
}
|
||||
let denom = ups + downs;
|
||||
result[i] = if denom != 0.0 {
|
||||
100.0 * (ups - downs) / denom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TRIX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TRIX: 1-period rate of change of triple-smoothed EMA.
|
||||
pub fn trix(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
let warmup = 3 * (timeperiod - 1);
|
||||
|
||||
// Triple EMA: EMA(EMA(EMA(close)))
|
||||
let ema1 = crate::overlap::ema(close, timeperiod);
|
||||
let ema2 = crate::overlap::ema(&ema1, timeperiod);
|
||||
let ema3 = crate::overlap::ema(&ema2, timeperiod);
|
||||
|
||||
for i in (warmup + 1)..n {
|
||||
let prev = ema3[i - 1];
|
||||
if !ema3[i].is_nan() && !prev.is_nan() && prev != 0.0 {
|
||||
result[i] = (ema3[i] - prev) / prev * 100.0;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ultimate Oscillator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ultimate Oscillator: weighted average of buying pressure over three periods.
|
||||
pub fn ultosc(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
timeperiod1: usize,
|
||||
timeperiod2: usize,
|
||||
timeperiod3: usize,
|
||||
) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod1 == 0 || timeperiod2 == 0 || timeperiod3 == 0 || n < 2 {
|
||||
return result;
|
||||
}
|
||||
let max_period = timeperiod1.max(timeperiod2).max(timeperiod3);
|
||||
if n <= max_period {
|
||||
return result;
|
||||
}
|
||||
|
||||
let mut bp = vec![0.0_f64; n];
|
||||
let mut tr = vec![0.0_f64; n];
|
||||
for i in 1..n {
|
||||
let true_low = low[i].min(close[i - 1]);
|
||||
let true_high = high[i].max(close[i - 1]);
|
||||
bp[i] = close[i] - true_low;
|
||||
tr[i] = true_high - true_low;
|
||||
}
|
||||
|
||||
for i in max_period..n {
|
||||
let avg = |period: usize| -> f64 {
|
||||
let sum_bp: f64 = bp[(i + 1 - period)..=i].iter().sum();
|
||||
let sum_tr: f64 = tr[(i + 1 - period)..=i].iter().sum();
|
||||
if sum_tr != 0.0 {
|
||||
sum_bp / sum_tr
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
result[i] =
|
||||
100.0 * (4.0 * avg(timeperiod1) + 2.0 * avg(timeperiod2) + avg(timeperiod3)) / 7.0;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -447,6 +447,526 @@ pub fn macd(
|
||||
(macd_line, signal_line, histogram)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEMA — Double Exponential Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Double Exponential Moving Average: `2*EMA - EMA(EMA)`.
|
||||
pub fn dema(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
let warmup = 2 * (timeperiod - 1);
|
||||
let ema1 = ema(close, timeperiod);
|
||||
let ema2 = ema(&ema1, timeperiod);
|
||||
for i in warmup..n {
|
||||
if !ema1[i].is_nan() && !ema2[i].is_nan() {
|
||||
result[i] = 2.0 * ema1[i] - ema2[i];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TEMA — Triple Exponential Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Triple Exponential Moving Average: `3*EMA - 3*EMA(EMA) + EMA(EMA(EMA))`.
|
||||
pub fn tema(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
let warmup = 3 * (timeperiod - 1);
|
||||
let ema1 = ema(close, timeperiod);
|
||||
let ema2 = ema(&ema1, timeperiod);
|
||||
let ema3 = ema(&ema2, timeperiod);
|
||||
for i in warmup..n {
|
||||
if !ema1[i].is_nan() && !ema2[i].is_nan() && !ema3[i].is_nan() {
|
||||
result[i] = 3.0 * ema1[i] - 3.0 * ema2[i] + ema3[i];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TRIMA — Triangular Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Triangular Moving Average (triangle-weighted).
|
||||
pub fn trima(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let half = timeperiod.div_ceil(2);
|
||||
let mut weights = Vec::with_capacity(timeperiod);
|
||||
for i in 1..=timeperiod {
|
||||
let w = if i <= half { i } else { timeperiod + 1 - i };
|
||||
weights.push(w as f64);
|
||||
}
|
||||
let weight_sum: f64 = weights.iter().sum();
|
||||
for i in (timeperiod - 1)..n {
|
||||
let mut val = 0.0_f64;
|
||||
for (j, &w) in weights.iter().enumerate() {
|
||||
val += close[i - (timeperiod - 1 - j)] * w;
|
||||
}
|
||||
result[i] = val / weight_sum;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KAMA — Kaufman Adaptive Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Kaufman Adaptive Moving Average.
|
||||
pub fn kama(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let fast_sc = 2.0 / 3.0_f64;
|
||||
let slow_sc = 2.0 / 31.0_f64;
|
||||
let mut kama_val = close[timeperiod - 1];
|
||||
result[timeperiod - 1] = kama_val;
|
||||
for i in timeperiod..n {
|
||||
let direction = (close[i] - close[i - timeperiod]).abs();
|
||||
let mut volatility = 0.0_f64;
|
||||
for j in 1..=timeperiod {
|
||||
volatility += (close[i - j + 1] - close[i - j]).abs();
|
||||
}
|
||||
let er = if volatility > 0.0 {
|
||||
direction / volatility
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2);
|
||||
kama_val += sc * (close[i] - kama_val);
|
||||
result[i] = kama_val;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T3 — Tillson T3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tillson T3: 6x smoothed EMA with volume factor.
|
||||
pub fn t3(close: &[f64], timeperiod: usize, vfactor: f64) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
let k = 2.0 / (timeperiod as f64 + 1.0);
|
||||
let v = vfactor;
|
||||
let c1 = -(v * v * v);
|
||||
let c2 = 3.0 * v * v + 3.0 * v * v * v;
|
||||
let c3 = -6.0 * v * v - 3.0 * v - 3.0 * v * v * v;
|
||||
let c4 = 1.0 + 3.0 * v + v * v * v + 3.0 * v * v;
|
||||
let warmup = 6 * (timeperiod - 1);
|
||||
let mut e = [0.0_f64; 6];
|
||||
for (i, &price) in close.iter().enumerate() {
|
||||
if i == 0 {
|
||||
for ej in e.iter_mut() {
|
||||
*ej = price;
|
||||
}
|
||||
} else {
|
||||
e[0] += k * (price - e[0]);
|
||||
for j in 1..6 {
|
||||
e[j] += k * (e[j - 1] - e[j]);
|
||||
}
|
||||
}
|
||||
if i >= warmup {
|
||||
result[i] = c1 * e[5] + c2 * e[4] + c3 * e[3] + c4 * e[2];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAR — Parabolic SAR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parabolic SAR.
|
||||
pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
if n < 2 {
|
||||
return vec![f64::NAN; n];
|
||||
}
|
||||
let mut result = vec![f64::NAN; n];
|
||||
let mut is_rising = high[1] >= high[0];
|
||||
let mut af = acceleration;
|
||||
let (mut ep, mut sar_val) = if is_rising {
|
||||
(high[1], low[0])
|
||||
} else {
|
||||
(low[1], high[0])
|
||||
};
|
||||
result[1] = sar_val;
|
||||
for i in 2..n {
|
||||
let prev_sar = sar_val;
|
||||
sar_val = prev_sar + af * (ep - prev_sar);
|
||||
if is_rising {
|
||||
sar_val = sar_val.min(low[i - 1]).min(low[i - 2]);
|
||||
if low[i] < sar_val {
|
||||
is_rising = false;
|
||||
sar_val = ep;
|
||||
ep = low[i];
|
||||
af = acceleration;
|
||||
} else if high[i] > ep {
|
||||
ep = high[i];
|
||||
af = (af + acceleration).min(maximum);
|
||||
}
|
||||
} else {
|
||||
sar_val = sar_val.max(high[i - 1]).max(high[i - 2]);
|
||||
if high[i] > sar_val {
|
||||
is_rising = true;
|
||||
sar_val = ep;
|
||||
ep = high[i];
|
||||
af = acceleration;
|
||||
} else if low[i] < ep {
|
||||
ep = low[i];
|
||||
af = (af + acceleration).min(maximum);
|
||||
}
|
||||
}
|
||||
result[i] = sar_val;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAREXT — Extended Parabolic SAR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parabolic SAR Extended with configurable acceleration factors.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn sarext(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
startvalue: f64,
|
||||
offsetonreverse: f64,
|
||||
accelerationinitlong: f64,
|
||||
accelerationlong: f64,
|
||||
accelerationmaxlong: f64,
|
||||
accelerationinitshort: f64,
|
||||
accelerationshort: f64,
|
||||
accelerationmaxshort: f64,
|
||||
) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
if n < 2 {
|
||||
return vec![f64::NAN; n];
|
||||
}
|
||||
let mut result = vec![f64::NAN; n];
|
||||
let mut is_rising = high[1] >= high[0];
|
||||
let (mut af, mut af_step_cur, mut af_max_cur) = if is_rising {
|
||||
(accelerationinitlong, accelerationlong, accelerationmaxlong)
|
||||
} else {
|
||||
(
|
||||
accelerationinitshort,
|
||||
accelerationshort,
|
||||
accelerationmaxshort,
|
||||
)
|
||||
};
|
||||
let (mut ep, mut sar_val) = if is_rising {
|
||||
(
|
||||
high[1],
|
||||
if startvalue != 0.0 {
|
||||
startvalue
|
||||
} else {
|
||||
low[0]
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
low[1],
|
||||
if startvalue != 0.0 {
|
||||
-startvalue
|
||||
} else {
|
||||
high[0]
|
||||
},
|
||||
)
|
||||
};
|
||||
result[1] = sar_val;
|
||||
for i in 2..n {
|
||||
let prev_sar = sar_val;
|
||||
sar_val = prev_sar + af * (ep - prev_sar);
|
||||
if is_rising {
|
||||
sar_val = sar_val.min(low[i - 1]).min(low[i - 2]);
|
||||
if low[i] < sar_val {
|
||||
is_rising = false;
|
||||
sar_val = ep + sar_val.abs() * offsetonreverse;
|
||||
ep = low[i];
|
||||
af = accelerationinitshort;
|
||||
af_step_cur = accelerationshort;
|
||||
af_max_cur = accelerationmaxshort;
|
||||
} else if high[i] > ep {
|
||||
ep = high[i];
|
||||
af = (af + af_step_cur).min(af_max_cur);
|
||||
}
|
||||
} else {
|
||||
sar_val = sar_val.max(high[i - 1]).max(high[i - 2]);
|
||||
if high[i] > sar_val {
|
||||
is_rising = true;
|
||||
sar_val = ep - sar_val.abs() * offsetonreverse;
|
||||
ep = high[i];
|
||||
af = accelerationinitlong;
|
||||
af_step_cur = accelerationlong;
|
||||
af_max_cur = accelerationmaxlong;
|
||||
} else if low[i] < ep {
|
||||
ep = low[i];
|
||||
af = (af + af_step_cur).min(af_max_cur);
|
||||
}
|
||||
}
|
||||
result[i] = sar_val;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAMA — MESA Adaptive Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// MESA Adaptive Moving Average. Returns `(mama, fama)`.
|
||||
pub fn mama(close: &[f64], fastlimit: f64, slowlimit: f64) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let lookback = 32;
|
||||
let mut mama_arr = vec![f64::NAN; n];
|
||||
let mut fama_arr = vec![f64::NAN; n];
|
||||
if n <= lookback {
|
||||
return (mama_arr, fama_arr);
|
||||
}
|
||||
|
||||
let mut smooth = vec![0.0f64; n];
|
||||
for i in 0..n {
|
||||
smooth[i] = if i >= 3 {
|
||||
(4.0 * close[i] + 3.0 * close[i - 1] + 2.0 * close[i - 2] + close[i - 3]) / 10.0
|
||||
} else {
|
||||
close[i]
|
||||
};
|
||||
}
|
||||
|
||||
let mut detrender = vec![0.0f64; n];
|
||||
let mut q1 = vec![0.0f64; n];
|
||||
let mut i1 = vec![0.0f64; n];
|
||||
let mut ji = vec![0.0f64; n];
|
||||
let mut jq = vec![0.0f64; n];
|
||||
let mut i2 = vec![0.0f64; n];
|
||||
let mut q2 = vec![0.0f64; n];
|
||||
let mut re = vec![0.0f64; n];
|
||||
let mut im = vec![0.0f64; n];
|
||||
let mut period = vec![0.0f64; n];
|
||||
let mut phase = vec![0.0f64; n];
|
||||
let mut mama_val = close[0];
|
||||
let mut fama_val = close[0];
|
||||
|
||||
for i in 6..n {
|
||||
let prev_period = period[i - 1].max(1.0);
|
||||
let alpha = 0.075 * prev_period + 0.54;
|
||||
detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2]
|
||||
- 0.5769 * smooth[i - 4]
|
||||
- 0.0962 * smooth[i - 6])
|
||||
* alpha;
|
||||
if i >= 12 {
|
||||
q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2]
|
||||
- 0.5769 * detrender[i - 4]
|
||||
- 0.0962 * detrender[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
if i >= 9 {
|
||||
i1[i] = detrender[i - 3];
|
||||
}
|
||||
if i >= 15 {
|
||||
ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
if i >= 18 {
|
||||
jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
let i2_raw = i1[i] - jq[i];
|
||||
let q2_raw = q1[i] + ji[i];
|
||||
i2[i] = 0.2 * i2_raw + 0.8 * i2[i - 1];
|
||||
q2[i] = 0.2 * q2_raw + 0.8 * q2[i - 1];
|
||||
re[i] = 0.2 * (i2[i] * i2[i - 1] + q2[i] * q2[i - 1]) + 0.8 * re[i - 1];
|
||||
im[i] = 0.2 * (i2[i] * q2[i - 1] - q2[i] * i2[i - 1]) + 0.8 * im[i - 1];
|
||||
let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 {
|
||||
std::f64::consts::PI * 2.0 / (im[i] / re[i]).atan()
|
||||
} else {
|
||||
prev_period
|
||||
};
|
||||
p = p
|
||||
.clamp(0.67 * prev_period, 1.5 * prev_period)
|
||||
.clamp(6.0, 50.0);
|
||||
period[i] = 0.2 * p + 0.8 * prev_period;
|
||||
phase[i] = if i1[i] != 0.0 {
|
||||
q1[i].atan2(i1[i]) * 180.0 / std::f64::consts::PI
|
||||
} else if q1[i] > 0.0 {
|
||||
90.0
|
||||
} else if q1[i] < 0.0 {
|
||||
-90.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut delta_phase = phase[i - 1] - phase[i];
|
||||
if delta_phase < 1.0 {
|
||||
delta_phase = 1.0;
|
||||
}
|
||||
let adaptive_alpha = (fastlimit / delta_phase).clamp(slowlimit, fastlimit);
|
||||
if i >= lookback {
|
||||
mama_val = adaptive_alpha * close[i] + (1.0 - adaptive_alpha) * mama_val;
|
||||
fama_val = 0.5 * adaptive_alpha * mama_val + (1.0 - 0.5 * adaptive_alpha) * fama_val;
|
||||
mama_arr[i] = mama_val;
|
||||
fama_arr[i] = fama_val;
|
||||
} else {
|
||||
mama_val = close[i];
|
||||
fama_val = close[i];
|
||||
}
|
||||
}
|
||||
(mama_arr, fama_arr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MIDPOINT / MIDPRICE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Midpoint: `(max(close) + min(close)) / 2` over rolling window.
|
||||
pub fn midpoint(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let window = &close[(i + 1 - timeperiod)..=i];
|
||||
let mx = window.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let mn = window.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
result[i] = (mx + mn) / 2.0;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// MidPrice: `(highest_high + lowest_low) / 2` over rolling window.
|
||||
pub fn midprice(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let start = i + 1 - timeperiod;
|
||||
let mx = high[start..=i]
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let mn = low[start..=i].iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
result[i] = (mx + mn) / 2.0;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MACDFIX / MACDEXT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// MACD with fixed 12/26 periods.
|
||||
pub fn macdfix(close: &[f64], signalperiod: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
macd(close, 12, 26, signalperiod)
|
||||
}
|
||||
|
||||
/// Compute MA by type: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3.
|
||||
fn compute_ma_by_type(close: &[f64], timeperiod: usize, matype: u8) -> Vec<f64> {
|
||||
match matype {
|
||||
0 => sma(close, timeperiod),
|
||||
1 => ema(close, timeperiod),
|
||||
2 => wma(close, timeperiod),
|
||||
3 => dema(close, timeperiod),
|
||||
4 => tema(close, timeperiod),
|
||||
5 => trima(close, timeperiod),
|
||||
6 => kama(close, timeperiod),
|
||||
7 => t3(close, timeperiod, 0.7),
|
||||
_ => sma(close, timeperiod),
|
||||
}
|
||||
}
|
||||
|
||||
/// MACD with configurable MA types for fast/slow/signal.
|
||||
pub fn macdext(
|
||||
close: &[f64],
|
||||
fastperiod: usize,
|
||||
fastmatype: u8,
|
||||
slowperiod: usize,
|
||||
slowmatype: u8,
|
||||
signalperiod: usize,
|
||||
signalmatype: u8,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]);
|
||||
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod {
|
||||
return nan3();
|
||||
}
|
||||
let fast_ma = compute_ma_by_type(close, fastperiod, fastmatype);
|
||||
let slow_ma = compute_ma_by_type(close, slowperiod, slowmatype);
|
||||
let macd_start = slowperiod - 1;
|
||||
let mut macd_line = vec![f64::NAN; n];
|
||||
for i in macd_start..n {
|
||||
if !fast_ma[i].is_nan() && !slow_ma[i].is_nan() {
|
||||
macd_line[i] = fast_ma[i] - slow_ma[i];
|
||||
}
|
||||
}
|
||||
let macd_valid: Vec<f64> = macd_line[macd_start..].to_vec();
|
||||
let signal_slice = compute_ma_by_type(&macd_valid, signalperiod, signalmatype);
|
||||
let mut signal_line = vec![f64::NAN; n];
|
||||
let warmup = macd_start + signalperiod - 1;
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in warmup..n {
|
||||
let j = i - macd_start;
|
||||
if j < signal_slice.len() && !signal_slice[j].is_nan() {
|
||||
signal_line[i] = signal_slice[j];
|
||||
}
|
||||
}
|
||||
let mut histogram = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
if !macd_line[i].is_nan() && !signal_line[i].is_nan() {
|
||||
histogram[i] = macd_line[i] - signal_line[i];
|
||||
}
|
||||
}
|
||||
(macd_line, signal_line, histogram)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MA (generic dispatcher) / MAVP (variable period)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generic Moving Average. matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3.
|
||||
pub fn ma(close: &[f64], timeperiod: usize, matype: u8) -> Vec<f64> {
|
||||
compute_ma_by_type(close, timeperiod, matype)
|
||||
}
|
||||
|
||||
/// Moving Average with Variable Period per bar (SMA over period from periods array).
|
||||
pub fn mavp(close: &[f64], periods: &[f64], minperiod: usize, maxperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if minperiod == 0 || maxperiod < minperiod {
|
||||
return result;
|
||||
}
|
||||
for i in 0..n {
|
||||
if i >= periods.len() {
|
||||
break;
|
||||
}
|
||||
let p = (periods[i].round() as usize).clamp(minperiod, maxperiod);
|
||||
if i + 1 >= p {
|
||||
let sum: f64 = close[(i + 1 - p)..=i].iter().sum();
|
||||
result[i] = sum / p as f64;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -24,6 +24,229 @@ pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Rolling population variance, scaled by `nbdev²`.
|
||||
pub fn var(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let window = &real[i + 1 - timeperiod..=i];
|
||||
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
|
||||
let variance: f64 =
|
||||
window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / timeperiod as f64;
|
||||
result[i] = variance * nbdev * nbdev;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linear regression helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn rolling_linreg_apply<F>(prices: &[f64], timeperiod: usize, mut map: F) -> Vec<f64>
|
||||
where
|
||||
F: FnMut(f64, f64) -> f64,
|
||||
{
|
||||
let n = prices.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let period = timeperiod as f64;
|
||||
let last_x = (timeperiod - 1) as f64;
|
||||
let sum_x = last_x * period / 2.0;
|
||||
let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0;
|
||||
let denom = period * sum_x2 - sum_x * sum_x;
|
||||
|
||||
let mut sum_y: f64 = prices[..timeperiod].iter().sum();
|
||||
let mut sum_xy: f64 = prices[..timeperiod]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, &v)| idx as f64 * v)
|
||||
.sum();
|
||||
|
||||
for end in (timeperiod - 1)..n {
|
||||
let slope = if denom != 0.0 {
|
||||
(period * sum_xy - sum_x * sum_y) / denom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let intercept = (sum_y - slope * sum_x) / period;
|
||||
result[end] = map(slope, intercept);
|
||||
if end + 1 < n {
|
||||
let outgoing = prices[end + 1 - timeperiod];
|
||||
let incoming = prices[end + 1];
|
||||
let prev_sum_y = sum_y;
|
||||
sum_y = prev_sum_y - outgoing + incoming;
|
||||
sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Linear regression fitted value at the last point of the window.
|
||||
pub fn linearreg(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let last_x = if timeperiod > 0 {
|
||||
(timeperiod - 1) as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
|
||||
intercept + slope * last_x
|
||||
})
|
||||
}
|
||||
|
||||
/// Slope of the rolling linear regression line.
|
||||
pub fn linearreg_slope(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
rolling_linreg_apply(close, timeperiod, |slope, _| slope)
|
||||
}
|
||||
|
||||
/// Intercept of the rolling linear regression line.
|
||||
pub fn linearreg_intercept(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
rolling_linreg_apply(close, timeperiod, |_, intercept| intercept)
|
||||
}
|
||||
|
||||
/// Angle of the regression line in degrees.
|
||||
pub fn linearreg_angle(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
rolling_linreg_apply(close, timeperiod, |slope, _| {
|
||||
slope.atan() * 180.0 / std::f64::consts::PI
|
||||
})
|
||||
}
|
||||
|
||||
/// Time Series Forecast: linear regression extrapolated one period ahead.
|
||||
pub fn tsf(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let forecast_x = timeperiod as f64;
|
||||
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
|
||||
intercept + slope * forecast_x
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beta (rolling, return-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rolling beta: regression of real1 daily returns on real0 daily returns.
|
||||
pub fn beta(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real0.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n <= timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
let price_return = |curr: f64, prev: f64| -> f64 {
|
||||
if prev != 0.0 {
|
||||
curr / prev - 1.0
|
||||
} else {
|
||||
f64::NAN
|
||||
}
|
||||
};
|
||||
let rx: Vec<f64> = real0.windows(2).map(|w| price_return(w[1], w[0])).collect();
|
||||
let ry: Vec<f64> = real1.windows(2).map(|w| price_return(w[1], w[0])).collect();
|
||||
|
||||
let period = timeperiod as f64;
|
||||
let mut sum_rx = 0.0_f64;
|
||||
let mut sum_ry = 0.0_f64;
|
||||
let mut sum_rx2 = 0.0_f64;
|
||||
let mut sum_rxry = 0.0_f64;
|
||||
let mut invalid = 0usize;
|
||||
|
||||
for idx in 0..timeperiod {
|
||||
let (ret_x, ret_y) = (rx[idx], ry[idx]);
|
||||
if ret_x.is_finite() && ret_y.is_finite() {
|
||||
sum_rx += ret_x;
|
||||
sum_ry += ret_y;
|
||||
sum_rx2 += ret_x * ret_x;
|
||||
sum_rxry += ret_x * ret_y;
|
||||
} else {
|
||||
invalid += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for end in timeperiod..n {
|
||||
result[end] = if invalid == 0 {
|
||||
let denom = period * sum_rx2 - sum_rx * sum_rx;
|
||||
if denom != 0.0 {
|
||||
(period * sum_rxry - sum_rx * sum_ry) / denom
|
||||
} else {
|
||||
f64::NAN
|
||||
}
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
|
||||
if end + 1 < n {
|
||||
let out = end - timeperiod;
|
||||
let (ox, oy) = (rx[out], ry[out]);
|
||||
if ox.is_finite() && oy.is_finite() {
|
||||
sum_rx -= ox;
|
||||
sum_ry -= oy;
|
||||
sum_rx2 -= ox * ox;
|
||||
sum_rxry -= ox * oy;
|
||||
} else {
|
||||
invalid -= 1;
|
||||
}
|
||||
let (ix, iy) = (rx[end], ry[end]);
|
||||
if ix.is_finite() && iy.is_finite() {
|
||||
sum_rx += ix;
|
||||
sum_ry += iy;
|
||||
sum_rx2 += ix * ix;
|
||||
sum_rxry += ix * iy;
|
||||
} else {
|
||||
invalid += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Correlation (rolling Pearson)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rolling Pearson correlation coefficient between two series.
|
||||
pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real0.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
let period = timeperiod as f64;
|
||||
let mut sum_x: f64 = real0[..timeperiod].iter().sum();
|
||||
let mut sum_y: f64 = real1[..timeperiod].iter().sum();
|
||||
let mut sum_x2: f64 = real0[..timeperiod].iter().map(|v| v * v).sum();
|
||||
let mut sum_y2: f64 = real1[..timeperiod].iter().map(|v| v * v).sum();
|
||||
let mut sum_xy: f64 = real0[..timeperiod]
|
||||
.iter()
|
||||
.zip(real1[..timeperiod].iter())
|
||||
.map(|(&a, &b)| a * b)
|
||||
.sum();
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for end in (timeperiod - 1)..n {
|
||||
let denom_x = period * sum_x2 - sum_x * sum_x;
|
||||
let denom_y = period * sum_y2 - sum_y * sum_y;
|
||||
result[end] = if denom_x > 0.0 && denom_y > 0.0 {
|
||||
(period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt()
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
|
||||
if end + 1 < n {
|
||||
let out = end + 1 - timeperiod;
|
||||
let inc = end + 1;
|
||||
sum_x += real0[inc] - real0[out];
|
||||
sum_y += real1[inc] - real1[out];
|
||||
sum_x2 += real0[inc] * real0[inc] - real0[out] * real0[out];
|
||||
sum_y2 += real1[inc] * real1[inc] - real1[out] * real1[out];
|
||||
sum_xy += real0[inc] * real1[inc] - real0[out] * real1[out];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -62,6 +62,22 @@ pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Normalized Average True Range: `ATR / close * 100`.
|
||||
pub fn natr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let atr_vals = atr(high, low, close, timeperiod);
|
||||
atr_vals
|
||||
.iter()
|
||||
.zip(close.iter())
|
||||
.map(|(&a, &c)| {
|
||||
if a.is_nan() || c == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
a / c * 100.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+6601
-6219
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
||||
Release Notes
|
||||
=============
|
||||
|
||||
These docs track package version ``1.1.0``.
|
||||
These docs track package version ``1.1.2``.
|
||||
|
||||
1.1.0-audit (2026-03-28)
|
||||
------------------------
|
||||
|
||||
@@ -180,7 +180,7 @@ For source builds, packaging details, and platform notes, see
|
||||
Release status
|
||||
--------------
|
||||
|
||||
These docs track package version ``1.1.0``.
|
||||
These docs track package version ``1.1.2``.
|
||||
|
||||
- Release notes by version: :doc:`changelog`
|
||||
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "ferro-ta"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -192,7 +192,7 @@ def _extract_wasm_exports(root: Path) -> list[str]:
|
||||
return sorted(exports)
|
||||
|
||||
# Fallback to generated declarations if source parsing did not find exports.
|
||||
dts_path = root / "wasm" / "pkg" / "ferro_ta_wasm.d.ts"
|
||||
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()
|
||||
|
||||
@@ -132,7 +132,7 @@ run_wasm() {
|
||||
cd wasm
|
||||
trap 'rm -f "$benchmark_json"' EXIT
|
||||
run_cmd wasm-pack test --node
|
||||
run_cmd wasm-pack build --target nodejs --out-dir pkg
|
||||
run_cmd npm run build
|
||||
run_cmd node bench.js --json "$benchmark_json"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ SCRIPT = WASM_DIR / "conformance_node.js"
|
||||
def _write_node_conformance_script(path: Path) -> None:
|
||||
path.write_text(
|
||||
"""
|
||||
const wasm = require("./pkg/ferro_ta_wasm.js");
|
||||
const wasm = require("./node/ferro_ta_wasm.js");
|
||||
|
||||
function toArray(x) {
|
||||
return Array.from(x, (v) => (Number.isNaN(v) ? null : Number(v)));
|
||||
|
||||
@@ -950,7 +950,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro-ta"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
|
||||
Generated
+2
-2
@@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
dependencies = [
|
||||
"ferro_ta_core",
|
||||
"js-sys",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
edition = "2021"
|
||||
description = "WebAssembly bindings for ferro-ta technical analysis indicators"
|
||||
license = "MIT"
|
||||
|
||||
+62
-110
@@ -1,53 +1,42 @@
|
||||
# ferro-ta WASM
|
||||
|
||||
WebAssembly bindings for the [ferro-ta](https://github.com/pratikbhadane24/ferro-ta) technical analysis library.
|
||||
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
|
||||
|
||||
Once published, install the Node.js build from npm:
|
||||
|
||||
```bash
|
||||
npm install ferro-ta-wasm
|
||||
```
|
||||
|
||||
```javascript
|
||||
const { sma, ema, wma, rsi, adx, mfi, bbands, atr, obv, macd } = require('ferro-ta-wasm');
|
||||
const ferro = require('ferro-ta-wasm');
|
||||
|
||||
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]);
|
||||
const smaOut = sma(close, 3);
|
||||
console.log('SMA:', Array.from(smaOut));
|
||||
console.log('SMA:', Array.from(ferro.sma(close, 3)));
|
||||
console.log('RSI:', Array.from(ferro.rsi(close, 14)));
|
||||
```
|
||||
|
||||
> **Decision**: We chose WebAssembly (wasm-bindgen / wasm-pack) as the second binding because it runs in
|
||||
> browsers *and* Node.js without any native addons, and shares zero unsafe FFI surface with the Python
|
||||
> build. Node.js users get a pure-JS entry point; browser users get the same `.wasm` file.
|
||||
## Available Indicators (200+ exports)
|
||||
|
||||
## Available Indicators
|
||||
|
||||
| Category | Function | Parameters | Returns |
|
||||
|------------|---------------|----------------------------------------------------|---------|
|
||||
| Overlap | `sma` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `ema` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `wma` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `bbands` | `close, timeperiod, nbdevup, nbdevdn` | `Array[upper, middle, lower]` |
|
||||
| Momentum | `rsi` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Momentum | `adx` | `high, low, close: Float64Array, timeperiod` | `Float64Array` |
|
||||
| Momentum | `macd` | `close, fastperiod, slowperiod, signalperiod` | `Array[macd, signal, hist]` |
|
||||
| Momentum | `mom` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Momentum | `stochf` | `high, low, close, fastk_period, fastd_period` | `Array[fastk, fastd]` |
|
||||
| Volatility | `atr` | `high, low, close: Float64Array, timeperiod` | `Float64Array` |
|
||||
| Volume | `obv` | `close: Float64Array, volume: Float64Array` | `Float64Array` |
|
||||
| Volume | `mfi` | `high, low, close, volume: Float64Array, timeperiod` | `Float64Array` |
|
||||
|
||||
### Adding more indicators
|
||||
|
||||
WASM exports live in `src/lib.rs` and can either implement logic directly or delegate to `ferro_ta_core`.
|
||||
To add a new indicator:
|
||||
|
||||
1. Add a `#[wasm_bindgen]` export in `src/lib.rs` (prefer delegating to `ferro_ta_core` where possible).
|
||||
2. Add at least two `#[wasm_bindgen_test]` tests covering output length and a known value.
|
||||
3. Update this README table.
|
||||
4. Run `wasm-pack test --node` to verify.
|
||||
| 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
|
||||
|
||||
@@ -57,91 +46,64 @@ 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
|
||||
# OR via cargo:
|
||||
cargo install wasm-pack
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd wasm/
|
||||
wasm-pack build --target nodejs --out-dir pkg
|
||||
|
||||
# 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 a `pkg/` directory containing:
|
||||
- `ferro_ta_wasm.js` — JavaScript glue code
|
||||
- `ferro_ta_wasm_bg.wasm` — compiled WebAssembly binary
|
||||
- `ferro_ta_wasm.d.ts` — TypeScript declarations
|
||||
This produces two directories:
|
||||
- `node/` -- CommonJS glue for Node.js (`require()`)
|
||||
- `web/` -- ESM glue for browsers and web workers (`import`)
|
||||
|
||||
For a browser build:
|
||||
|
||||
```bash
|
||||
wasm-pack build --target web --out-dir pkg-web
|
||||
```
|
||||
Both contain `ferro_ta_wasm.js`, `ferro_ta_wasm_bg.wasm`, and `ferro_ta_wasm.d.ts`.
|
||||
|
||||
## Usage (Node.js)
|
||||
|
||||
```javascript
|
||||
const { sma, ema, wma, rsi, adx, mfi, bbands, atr, obv, macd } = require('./pkg/ferro_ta_wasm.js');
|
||||
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]);
|
||||
|
||||
// Simple Moving Average (period 3)
|
||||
const smaOut = sma(close, 3);
|
||||
console.log('SMA:', Array.from(smaOut)); // [ NaN, NaN, 44.193, ... ]
|
||||
// Indicators
|
||||
console.log('SMA:', Array.from(sma(close, 3)));
|
||||
console.log('RSI:', Array.from(rsi(close, 5)));
|
||||
|
||||
// RSI (period 5)
|
||||
const rsiOut = rsi(close, 5);
|
||||
console.log('RSI:', Array.from(rsiOut));
|
||||
|
||||
// WMA (period 5)
|
||||
const wmaOut = wma(close, 5);
|
||||
console.log('WMA:', Array.from(wmaOut));
|
||||
|
||||
// Bollinger Bands (period 5, ±2σ) — returns [upper, middle, lower]
|
||||
// Multi-output
|
||||
const [upper, middle, lower] = bbands(close, 5, 2.0, 2.0);
|
||||
console.log('BBANDS upper:', Array.from(upper));
|
||||
const [macdLine, signal, hist] = macd(close, 3, 5, 2);
|
||||
|
||||
// MACD (fast=3, slow=5, signal=2) — returns [macd_line, signal_line, histogram]
|
||||
const [macdLine, signalLine, histogram] = macd(close, 3, 5, 2);
|
||||
console.log('MACD:', Array.from(macdLine));
|
||||
console.log('Signal:', Array.from(signalLine));
|
||||
console.log('Histogram:', Array.from(histogram));
|
||||
|
||||
// ATR (period 3)
|
||||
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]);
|
||||
const atrOut = atr(high, low, close, 3);
|
||||
console.log('ATR:', Array.from(atrOut));
|
||||
|
||||
// ADX (period 3)
|
||||
const adxOut = adx(high, low, close, 3);
|
||||
console.log('ADX:', Array.from(adxOut));
|
||||
|
||||
// OBV
|
||||
const volume = new Float64Array([1000, 1200, 900, 1500, 800, 600, 700]);
|
||||
const obvOut = obv(close, volume);
|
||||
console.log('OBV:', Array.from(obvOut));
|
||||
|
||||
// MFI (period 3)
|
||||
const mfiOut = mfi(high, low, close, volume, 3);
|
||||
console.log('MFI:', Array.from(mfiOut));
|
||||
// Streaming (bar-by-bar)
|
||||
const stream = new WasmStreamingSMA(3);
|
||||
for (const price of close) {
|
||||
console.log('streaming SMA:', stream.update(price));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage (Browser)
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import init, { sma, macd } from './pkg-web/ferro_ta_wasm.js';
|
||||
await init(); // loads the .wasm binary
|
||||
import init, { sma, rsi, macd } from './pkg-web/ferro_ta_wasm.js';
|
||||
await init();
|
||||
|
||||
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33]);
|
||||
const smaOut = sma(close, 3);
|
||||
console.log('SMA:', Array.from(smaOut));
|
||||
|
||||
// MACD
|
||||
const [macdLine, signal, hist] = macd(close, 3, 5, 2);
|
||||
console.log('MACD line:', Array.from(macdLine));
|
||||
console.log('SMA:', Array.from(sma(close, 3)));
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -152,22 +114,12 @@ cd wasm/
|
||||
wasm-pack test --node
|
||||
```
|
||||
|
||||
## CI Artifact
|
||||
|
||||
Every CI run on `main` builds the WASM package and uploads it as a GitHub Actions
|
||||
artifact named `wasm-pkg`. To download the latest pre-built package without building
|
||||
from source:
|
||||
|
||||
1. Go to the [Actions tab](https://github.com/pratikbhadane24/ferro-ta/actions).
|
||||
2. Open the latest successful CI run.
|
||||
3. Download the `wasm-pkg` artifact from the **Artifacts** section.
|
||||
4. Unzip and use `pkg/ferro_ta_wasm.js` directly in your project.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only 12 indicators are currently exposed (SMA, EMA, WMA, BBANDS, RSI, ADX, MACD, MOM, STOCHF, ATR, OBV, MFI).
|
||||
Additional indicators will be added following the same pattern in `src/lib.rs`.
|
||||
- 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).
|
||||
- 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
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { performance } = require("node:perf_hooks");
|
||||
|
||||
const wasm = require("./pkg/ferro_ta_wasm.js");
|
||||
const wasm = require("./node/ferro_ta_wasm.js");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { bars: 100000, json: null };
|
||||
|
||||
+21
-6
@@ -1,14 +1,29 @@
|
||||
{
|
||||
"name": "ferro-ta-wasm",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.2",
|
||||
"description": "WebAssembly bindings for ferro-ta technical analysis indicators",
|
||||
"main": "pkg/ferro_ta_wasm.js",
|
||||
"types": "pkg/ferro_ta_wasm.d.ts",
|
||||
"files": ["pkg"],
|
||||
"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": "wasm-pack build --target nodejs --out-dir pkg",
|
||||
"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 && node -e \"require('fs').rmSync('pkg/.gitignore', { force: true })\"",
|
||||
"prepack": "npm run build",
|
||||
"test": "wasm-pack test --node"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
+1467
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user